Compare commits

...

88 Commits
v0.2.2 ... main

Author SHA1 Message Date
Paul van Tilburg ba1b27fd66
Bump the version to 0.2.12
Check, lint and test using Cargo / Check, lint and test (push) Failing after 9m37s Details
Release / Release Rust crate (push) Successful in 4m37s Details
Release / Release Debian package (push) Has been cancelled Details
Release / Release (push) Has been cancelled Details
2024-05-09 12:29:47 +02:00
Paul van Tilburg 94c29cad71
Update the changelog 2024-05-09 12:28:47 +02:00
Paul van Tilburg 8e5a1ef305
Bump dependency on reqwest to 0.12.4 2024-05-09 12:25:46 +02:00
Paul van Tilburg 6c39cac26e
Bump dependency on image to 0.25.1
Also only enable the `png` feature and update a deprecated output format
definition.
2024-05-09 12:24:44 +02:00
Paul van Tilburg f1ee03d96c
Bump dependency on chrono-tz to 0.9.0 2024-05-09 12:24:41 +02:00
Paul van Tilburg 98f60cba89
Bump dependency on cached to 0.51.3 2024-05-09 12:24:39 +02:00
Paul van Tilburg 45ee951601
Cargo update; fixes RUSTSEC-2024-0019 and RUSTSEC-2024-0332 2024-05-09 12:12:58 +02:00
Paul van Tilburg f4e7c82b53
Fix test; remove maps load/maps test race
Check, lint and test using Cargo / Check, lint and test (push) Successful in 4m39s Details
Introduce the Rocket core that can be used to test the API without
background processes interfering with the test setup.
2024-05-09 12:04:00 +02:00
Paul van Tilburg a29d7f3535
Fix test; reduce require accurancy for coordinates
There are currently two entries for the city of Eindhoven in the
Nomanitim geoecoding database. Reduced acccuracy still is enough to
check that the API works properly.
2024-05-09 12:03:48 +02:00
Paul van Tilburg c86f001fee
Fix clippy issues
Check, lint and test using Cargo / Check, lint and test (push) Failing after 5m23s Details
2024-05-03 14:34:46 +02:00
Paul van Tilburg 88c59cdb1f
Bump the version to 0.2.11
Check, lint and test using Cargo / Check, lint and test (push) Successful in 4m18s Details
Release / Release (push) Successful in 1m3s Details
Release / Release Rust crate (push) Successful in 3m1s Details
Release / Release Debian package (push) Successful in 5m12s Details
2024-02-27 16:07:22 +01:00
Paul van Tilburg cad766b520
Update the changelog 2024-02-27 16:07:19 +01:00
Paul van Tilburg e62699c102
Tweak/fix tests; reduce required accuracy for geocoded coordinates
Also somebody seems to have slightly moved Eindhoven.
2024-02-27 16:06:56 +01:00
Paul van Tilburg f32f67dbf4
Fix clippy issue 2024-02-27 16:00:57 +01:00
Paul van Tilburg d1e43a7aa7
Cargo update; fixes several security advisories
Fixes RUSTSEC-2024-0003 and RUSTSEC-2023-0072.
2024-02-27 15:59:44 +01:00
Paul van Tilburg c2450267e0
Bump the version to 0.2.10
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m42s Details
Release / Release (push) Successful in 1m35s Details
Release / Release Rust crate (push) Successful in 4m10s Details
Release / Release Debian package (push) Successful in 6m3s Details
2023-11-03 10:41:56 +01:00
Paul van Tilburg 087ecf00f1
Update the changelog 2023-11-03 10:40:43 +01:00
Paul van Tilburg f8ea25c516
Bump the dependency on cached to 0.46.0 2023-11-03 10:39:49 +01:00
Paul van Tilburg f830d34464
Cargo update; fixes RUSTSEC-2020-0071 and RUSTSEC-2023-0044
Fix the tests for small changes in Rocket 0.5-rc.4.
Also fix the usage of a deprecate method.
2023-11-03 10:39:47 +01:00
Paul van Tilburg ff10cc19e8
Correct Debian package file pattern
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m34s Details
2023-08-25 21:23:11 +02:00
Paul van Tilburg 1211fea46a
Bump the version to 0.2.9
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m10s Details
Release / Release (push) Successful in 1m47s Details
Release / Release Rust crate (push) Successful in 4m28s Details
Release / Release Debian package (push) Failing after 6m22s Details
2023-08-25 20:48:46 +02:00
Paul van Tilburg 182521aab7
Update the changelog 2023-08-25 20:48:17 +02:00
Paul van Tilburg dadf5d3147
Fix clippy issue
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m9s Details
2023-08-25 20:24:19 +02:00
Paul van Tilburg 4b506541f3
Build and release a Debian package in a separate job
Check, lint and test using Cargo / Check, lint and test (push) Failing after 3m34s Details
Release it to the package repository instead of attaching to the release.
Also add the relevant part of the changelog as release notes to the
release and fix some schema-related issues.
2023-08-25 20:15:39 +02:00
Paul van Tilburg 47e28a7098
Cargo update 2023-08-25 20:06:56 +02:00
Paul van Tilburg 07e0701106
Bump the version to 0.2.8
Check, lint and test using Cargo / Check, lint and test (push) Successful in 12m45s Details
Release / Release (push) Successful in 12m16s Details
Release / Release crate (push) Successful in 10m0s Details
2023-06-04 12:13:46 +02:00
Paul van Tilburg 91d5500c86
Update the changelog 2023-06-04 12:13:19 +02:00
Paul van Tilburg 9b3c11ee76
Cargo update 2023-06-04 12:12:54 +02:00
Paul van Tilburg 27e1ac726c
Bump dependency on cached to 0.44.0 2023-06-04 12:12:54 +02:00
Paul van Tilburg 3047cf74c2
No longer configure using a sparse Cargo index for crates.io
This is the default since Rust 1.70.
2023-06-04 12:02:35 +02:00
Paul van Tilburg 44474aa545
Tweak README
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m44s Details
2023-05-29 16:38:42 +02:00
Paul van Tilburg 50b0e94839
Properly attribute the PAQI metric
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m45s Details
2023-05-29 16:37:16 +02:00
Paul van Tilburg 1010311403
Don't provide the map for the PAQI metric (it is pollen only) 2023-05-29 16:36:02 +02:00
Paul van Tilburg d16699636b Merge pull request 'Print the version on lift off and add version endpoint' (#30) from 29-print-version-add-endpoint into main
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m52s Details
Reviewed-on: #30
2023-05-29 16:14:56 +02:00
Paul van Tilburg 38fb28c248 Add a /version API endpoint
Check, lint and test using Cargo / Check, lint and test (pull_request) Successful in 6m5s Details
Check, lint and test using Cargo / Check, lint and test (push) Has been cancelled Details
* Introduce the `VersionInfo` struct, build from the vergen environment
  variables
* Add the `version` handler to construct and return the version info
* Update the README
2023-05-29 15:48:36 +02:00
Paul van Tilburg 7c2b012e95 Print the version on lift off 2023-05-29 15:48:36 +02:00
Paul van Tilburg ab6001f072 Use the vergen crate to generate version information
* Add depend on the `vergen` crate (only use the `build`, `git` and
  `gitcl` features)
* Add the build script `build.rs` to setup the environment variables
  from the build system
2023-05-29 15:48:36 +02:00
Paul van Tilburg 9742331f6d
Annote the map key colors in the comments
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m42s Details
2023-05-26 20:44:24 +02:00
Paul van Tilburg 9bb9d248a8
Bump the version to 0.2.7
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m39s Details
Release / Release (push) Successful in 7m29s Details
Release / Release crate (push) Successful in 4m8s Details
2023-05-26 20:17:31 +02:00
Paul van Tilburg 37788fac1c
Update the changelog
Also add missing release dates!
2023-05-26 20:16:24 +02:00
Paul van Tilburg 112875e7ac
Use the personal Cargo token
Use this instead of the (missing) repository's secret.
2023-05-26 20:05:52 +02:00
Paul van Tilburg 1c71ca79ef
Switch back to the original Buienradar color scheme (refs: #27)
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m29s Details
This reverts commit a52313ffb7.
2023-05-26 19:43:37 +02:00
Paul van Tilburg afca20c96f
Bump the version to 0.2.6
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m35s Details
Release / Release (push) Successful in 8m17s Details
Release / Release crate (push) Failing after 4m12s Details
2023-05-24 22:17:59 +02:00
Paul van Tilburg 2d34eee49a
Update the changelog 2023-05-24 22:17:31 +02:00
Paul van Tilburg a52313ffb7
Switch to new Buienradar color scheme (closes: #27)
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m13s Details
2023-05-24 22:13:13 +02:00
Admar Schoonen f39a3a33ee
Set sampling area to 31x31 (closes: #26)
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m16s Details
2023-05-24 19:20:24 +02:00
Paul van Tilburg a59b4eefe1
Improve error description/comment 2023-05-24 19:16:04 +02:00
Paul van Tilburg 1aad3e2eb6
Nomatim seems to geocode Eindhoven differently now
Check, lint and test using Cargo / Check, lint and test (push) Successful in 5m32s Details
2023-05-22 20:50:46 +02:00
Paul van Tilburg 929508a9cc
Add a full release workflow
Check, lint and test using Cargo / Check, lint and test (push) Failing after 5m32s Details
2023-05-22 20:09:45 +02:00
Paul van Tilburg 23e4f731a0
Tweak step name 2023-05-22 20:08:36 +02:00
Paul van Tilburg d84440304a
Simplify Gitea Actions check, lint and test workflow
Check, lint and test using Cargo / Check, lint and test (push) Successful in 6m20s Details
2023-04-25 16:38:59 +02:00
Paul van Tilburg a289bd9ef0
Bump the version to 0.2.5
Check Details
Lints Details
Test Suite Details
2023-03-24 13:10:22 +01:00
Paul van Tilburg 122f98a92d
Update the changelog 2023-03-24 13:09:53 +01:00
Paul van Tilburg 1426405943
Bump dependencies on cached and chrono-tz 2023-03-24 13:05:42 +01:00
Paul van Tilburg 34be96d187
Update to Rocket 0.5.0-rc.3 2023-03-24 13:04:56 +01:00
Paul van Tilburg bc140a9d1e
Remove unnecessary debug statement
Check Details
Lints Details
Test Suite Details
2023-03-23 16:57:30 +01:00
Paul van Tilburg 39c224eb90
Cargo update; fixes RUSTSEC-2023-0018 2023-03-23 16:56:48 +01:00
Paul van Tilburg b517448fd7
Speed up workflow by using sparce Cargo index for crates.io
Check Details
Lints Details
Test Suite Details
2023-03-21 11:50:00 +01:00
Paul van Tilburg 3de66dbd41
Add Gitea Actions (CI) workflow for cargo
Check Details
Lints Details
Test Suite Details
2023-03-21 11:16:48 +01:00
Paul van Tilburg 6a04fc958f
Fix float comparison in tests 2023-03-21 11:15:32 +01:00
Paul van Tilburg c8b951ab7e
Fix clippy issue 2023-03-21 11:05:03 +01:00
Paul van Tilburg 32ec6b516c
Cargo update
This fixes build issues with the `geo-types` crate versio 0.7.7.
Also replace use of now deprecated functions/methods.
2023-01-31 14:02:46 +01:00
Paul van Tilburg a6301fa678 Fix markdownlint issues 2022-10-23 10:50:05 +02:00
Paul van Tilburg f00537d5f3
Add more lints; fix issues 2022-10-17 20:02:54 +02:00
Paul van Tilburg c8970fa3bb
Cargo update 2022-10-17 19:53:01 +02:00
Paul van Tilburg aee3409f4a Bump dependency on cached to 0.38.0
This fixes the unused `*_prime_cache` compile warnings.
2022-08-12 09:46:17 +02:00
Paul van Tilburg dbdd7bef0f Cargo update 2022-08-12 09:44:15 +02:00
Paul van Tilburg d749233b24 Merge uses 2022-08-12 09:44:08 +02:00
Paul van Tilburg abb6657212
Cargo update 2022-07-17 13:25:22 +02:00
Paul van Tilburg 8b03f2162b
Bump dependency on geocoding to 0.4.0
This finally removes the duplicate dependency tree on older versions of
crates we're already using (chrono, request, etc.).
2022-07-17 13:25:22 +02:00
Paul van Tilburg 789bb1d1ac
Update the changelog 2022-07-05 14:46:51 +02:00
Paul van Tilburg 2e999f5a78
Bump the version to 0.2.4 2022-07-05 14:42:51 +02:00
Paul van Tilburg 712b3a9acf
Check sample coordinate bounds (closes: #24) 2022-06-06 19:51:07 +02:00
Paul van Tilburg 2b23885692
Default to now if Last-Modified header missing
As a result, if the header is missing, it is no longer considered an
error.
2022-06-06 19:39:32 +02:00
Paul van Tilburg dc47c1c73c Merge pull request 'Handle errors internally and show them via the API' (#25) from handle-errors into main
Reviewed-on: #25
2022-06-06 16:49:03 +02:00
Paul van Tilburg 014ca5a151
Handle errors on the API side
* The map endpoints return an HTTP 404 error in case of unknown or
  out-of-bound locations
* The forecast endpoint with an address returns an HTTP 404 with error
  JSON in case geocoding fails
* The forecast endpoints return the errors per metric in the `errors`
  field of the forecast
* Implement `Display` for `Metric`
* Use a `BTreeMap` to have an ordered `errors` field/object
* Also log the errors to the console
* Update the tests
* Document the errors that can occur
2022-06-06 16:46:52 +02:00
Paul van Tilburg 8a2a6d769d
Log errors of the map refresher task separately 2022-06-06 15:38:38 +02:00
Paul van Tilburg 69ef08002c
Introduce error types, switch to Results everywhere
* Add dependency on the `thiserror` crate
* Add a global `Error` type, but also `maps::Error` and
  `providers::combined::MergeError` for convenience
* Add matching `Result` types that default to the respective `Error`
  type
* Refactor code to yield all kinds of error variants
* Add FIXMEs where library errors still need to be handled
* Remove documentation that explained why `None` was returned, this is
  captured in the error now
2022-06-06 15:37:54 +02:00
Paul van Tilburg 7d0cd4a822
Drop pollen and AQI max for PAQI metric
* This was introduced as per #20 but no longer deemed necessary
* Fix up some comments
* Keep the PAQI documentation in `README.md`
2022-06-05 21:47:12 +02:00
Paul van Tilburg fb8236696d
Use world map emoji! 2022-06-05 21:29:12 +02:00
Paul van Tilburg aab3b737be
Run map refresher as an ad hoc liftoff fairing
* Simplify the library `setup()` method
* Simplify launching Rocket
* Drop dependency on color-eyre
2022-06-05 21:25:56 +02:00
Paul van Tilburg 3451445de1
Update the changelog 2022-05-21 09:51:59 +02:00
Paul van Tilburg b88c7f77a0
Bump the version to 0.2.3 2022-05-21 09:49:18 +02:00
Paul van Tilburg 705ffae249
Fix typo in map key color for level 8 2022-05-21 09:47:23 +02:00
Paul van Tilburg bd2344beb6
Transform map key colors to hexadecimal format 2022-05-21 09:47:01 +02:00
Paul van Tilburg bad20b803a
Don't set unnecessary status 2022-05-10 15:47:37 +02:00
Paul van Tilburg fc4672328f
Fix missing type for tests
This was introduced by the changes in commit e204e79.
2022-05-10 15:47:08 +02:00
Paul van Tilburg 769f0745c8
Update examples with fixes of v0.2.2 taken into account 2022-05-10 15:26:58 +02:00
15 changed files with 2022 additions and 1776 deletions

View File

@ -0,0 +1,45 @@
name: "Check, lint and test using Cargo"
on:
- pull_request
- push
- workflow_dispatch
jobs:
check_lint:
name: Check, lint and test
runs-on: debian-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Install Rust stable toolchain
uses: https://github.com/actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: rustfmt, clippy
- name: Run cargo check
uses: https://github.com/actions-rs/cargo@v1
with:
command: check
- name: Run cargo clippy
uses: https://github.com/actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings
- name: Run cargo fmt
uses: https://github.com/actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
- name: Run cargo test
uses: https://github.com/actions-rs/cargo@v1
with:
command: test
args: --all-features

View File

@ -0,0 +1,112 @@
name: "Release"
on:
push:
tags:
- "v*"
jobs:
release:
name: "Release"
runs-on: debian-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Determine the version of the release
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Releasing version: $VERSION"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Get the release notes from the changelog
run: |
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
RELEASE_NOTES=$(sed -n -e "/^## \[$VERSION\]/,/^## \[/{//"'!'"p;}" CHANGELOG.md | sed -e '1d;$d')
echo "Release notes:"
echo
echo "$RELEASE_NOTES"
echo "RELEASE_NOTES<<$EOF" >> "$GITHUB_ENV"
echo "$RELEASE_NOTES" >> "$GITHUB_ENV"
echo "$EOF" >> "$GITHUB_ENV"
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: '>=1.20.1'
- name: Release to Gitea
uses: actions/release-action@main
with:
# This is available by default.
api_key: '${{ secrets.RELEASE_TOKEN }}'
files: FIXME
title: 'Release ${{ env.VERSION }}'
body: '${{ env.RELEASE_NOTES }}'
release-crate:
name: "Release Rust crate"
runs-on: debian-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Rust stable toolchain
uses: https://github.com/actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- name: Use sparse Cargo index for crates.io
run: echo -e '[registries.crates-io]\nprotocol = "sparse"' >> /root/.cargo/config.toml
- name: Register the Gitea crate registry with Cargo
run: echo -e '[registries.luon]\nindex = "https://git.luon.net/paul/_cargo-index.git"' >> /root/.cargo/config.toml
- name: Run cargo publish
uses: https://github.com/actions-rs/cargo@v1
env:
# This needs to be provided for the repository; no login necessary as a result.
CARGO_REGISTRIES_LUON_TOKEN: '${{ secrets.CARGO_TOKEN }}'
with:
command: publish
args: --registry luon
release-deb:
name: "Release Debian package"
runs-on: debian-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Rust stable toolchain
uses: https://github.com/actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- name: Install cargo-deb
uses: https://github.com/brndnmtthws/rust-action-cargo-binstall@v1
with:
packages: cargo-deb
- name: Run cargo-deb
uses: https://github.com/actions-rs/cargo@v1
with:
command: deb
- name: Publish Debian package
env:
DEB_REPO_TOKEN: '${{ secrets.DEB_REPO_TOKEN }}'
run: |
curl --config <(printf "user=%s:%s" paul "${DEB_REPO_TOKEN}") \
--upload-file target/debian/sinoptik*.deb \
https://git.luon.net/api/packages/paul/debian/pool/bookworm/main/upload

View File

@ -7,6 +7,156 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.12] - 2024-05-09
### Security
* Updated dependencies, fixes security advisiories:
* [RUSTSEC-2024-0019](https://rustsec.org/advisories/RUSTSEC-2024-0019)
* [RUSTSEC-2024-0332](https://rustsec.org/advisories/RUSTSEC-2024-0332)
### Changed
* Update dependency on `cached`, `chrono-tz`, `image` and `reqwest`
### Fixed
* Fix tests; reduce required accuracy for geocoded coordinates again and
don't run background map updates during tests
## [0.2.11] - 2024-02-27
### Security
* Updated dependencies, fixes security advisories:
* [RUSTSEC-2024-0003](https://rustsec.org/advisories/RUSTSEC-2024-0003)
* [RUSTSEC-2023-0072](https://rustsec.org/advisories/RUSTSEC-2024-0072)
### Fixed
* Fix clippy issue
* Tweak/fix tests; reduce required accuracy for geocoded coordinates
## [0.2.10] - 2023-11-03
### Security
* Update dependencies
([RUSTSEC-2020-0071](https://rustsec.org/advisories/RUSTSEC-2020-0071.html),
[RUSTSEC-2023-0044](https://rustsec.org/advisories/RUSTSEC-2023-0044.html))
### Changed
* Switch to Rocket 0.5 RC4
* Update dependency on `cached`
### Fixed
* Fix clippy issues
## [0.2.9] - 2023-08-25
### Changed
* Update release Gitea Actions workflow; add seperate job to release Debian
package to the new repository
### Security
* Update dependencies ([RUSTSEC-2023-0044](https://rustsec.org/advisories/RUSTSEC-2023-0044))
## [0.2.8] - 2023-06-05
### Added
* Print the version on lift off (#30)
* Add a `/version` endpoint to the API (#30)
### Changed
* Update dependency on `cached`
### Fixed
* Properly attribute the PAQI metric in its description(s)
### Removed
* No longer provide a map for the PAQI metric; the map used is only for pollen
## [0.2.7] - 2023-05-26
### Fixed
* Switch back to the original Buienradar color scheme/maps key (#27)
* Fix the token used to publish the crate to the Cargo package index
## [0.2.6] - 2023-05-24
### Added
* Add full release Gitea Actions workflow
### Changed
* Simplify Gitea Actions check, lint and test workflow
* Improve no known map colors found error description
### Fixed
* Update coordinates of Eindhoven in tests (Nomatim changed its geocoding)
* Increase sampling area to 31×31 pixels (#26)
* Switch to new Buienradar color scheme/maps key (#27)
## [0.2.5] - 2023-03-24
### Added
* Add Gitea Actions workflow for cargo
### Changed
* Updated dependencies on `cached`, `chrono-tz` and `geocoding`
### Fixed
* Fix float comparison in tests
* Fix clippy issues
### Security
* Update dependencies ([RUSTSEC-2023-0018](https://rustsec.org/advisories/RUSTSEC-2023-0018.html))
## [0.2.4] - 2022-07-05
### Added
* Add proper error handling and show them via the API (#25)
### Changed
* Run map refresher as an ad hoc liftoff fairing in Rocket
* Changed emojis in log output
### Removed
* Removed `AQI_max` and `pollen_max` from the forecast JSON introduced in
version 0.2.0
### Fixed
* Verify sample coordinate bounds (#24)
* Default to current time if `Last-Modified` HTTP header is missing for
retrieved maps
## [0.2.3] - 2022-05-21
### Fixed
* Update the examples in `README.md`
* Fix tests by adding missing type
* Fix map key color code for level 8 used by map sampling
## [0.2.2] - 2022-05-10 ## [0.2.2] - 2022-05-10
### Changed ### Changed
@ -39,7 +189,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Initial release. Initial release.
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.2...HEAD [Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.12...HEAD
[0.2.12]: https://git.luon.net/paul/sinoptik/compare/v0.2.11...v0.2.12
[0.2.11]: https://git.luon.net/paul/sinoptik/compare/v0.2.10...v0.2.11
[0.2.10]: https://git.luon.net/paul/sinoptik/compare/v0.2.9...v0.2.10
[0.2.9]: https://git.luon.net/paul/sinoptik/compare/v0.2.8...v0.2.9
[0.2.8]: https://git.luon.net/paul/sinoptik/compare/v0.2.7...v0.2.8
[0.2.7]: https://git.luon.net/paul/sinoptik/compare/v0.2.6...v0.2.7
[0.2.6]: https://git.luon.net/paul/sinoptik/compare/v0.2.5...v0.2.6
[0.2.5]: https://git.luon.net/paul/sinoptik/compare/v0.2.4...v0.2.5
[0.2.4]: https://git.luon.net/paul/sinoptik/compare/v0.2.3...v0.2.4
[0.2.3]: https://git.luon.net/paul/sinoptik/compare/v0.2.2...v0.2.3
[0.2.2]: https://git.luon.net/paul/sinoptik/compare/v0.2.1...v0.2.2 [0.2.2]: https://git.luon.net/paul/sinoptik/compare/v0.2.1...v0.2.2
[0.2.1]: https://git.luon.net/paul/sinoptik/compare/v0.2.0...v0.2.1 [0.2.1]: https://git.luon.net/paul/sinoptik/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.luon.net/paul/sinoptik/compare/v0.1.0...v0.2.0 [0.2.0]: https://git.luon.net/paul/sinoptik/compare/v0.1.0...v0.2.0

2403
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "sinoptik" name = "sinoptik"
version = "0.2.2" version = "0.2.12"
authors = [ authors = [
"Admar Schoonen <admar@luon.net", "Admar Schoonen <admar@luon.net",
"Paul van Tilburg <paul@luon.net>" "Paul van Tilburg <paul@luon.net>"
@ -12,15 +12,18 @@ repository = "https://git.luon.net/paul/sinoptik"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
cached = { version = "0.34.0", features = ["async"] } cached = { version = "0.51.3", features = ["async"] }
chrono = "0.4.19" chrono = "0.4.19"
chrono-tz = "0.6.1" chrono-tz = "0.9.0"
color-eyre = "0.6.1"
csv = "1.1.6" csv = "1.1.6"
geocoding = "0.3.1" geocoding = "0.4.0"
image = "0.24.1" image = { version = "0.25.1", default-features = false, features = ["png"]}
reqwest = { version = "0.11.9", features = ["json"] } reqwest = { version = "0.12.4", features = ["json"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] } rocket = { version = "0.5.0-rc.3", features = ["json"] }
thiserror = "1.0.31"
[build-dependencies]
vergen = { version = "8.2.1", default-features = false, features = ["build", "git", "gitcl"] }
[dev-dependencies] [dev-dependencies]
assert_float_eq = "1.1.3" assert_float_eq = "1.1.3"
@ -42,7 +45,8 @@ Currently supported metrics are:
* O concentration (per hour, from Luchtmeetnet) * O concentration (per hour, from Luchtmeetnet)
* Particulate matter (PM10) concentration (per hour, from Luchtmeetnet) * Particulate matter (PM10) concentration (per hour, from Luchtmeetnet)
* Pollen (per hour, from Buienradar) * Pollen (per hour, from Buienradar)
* Pollen/air quality index (per hour, from Buienradar) * Pollen/air quality index (per hour, combined from Buienradar and
Luchtmeetnet)
* Precipitation (per 5 minutes, from Buienradar) * Precipitation (per 5 minutes, from Buienradar)
* UV index (per day, from Buienradar) * UV index (per day, from Buienradar)

120
README.md
View File

@ -11,7 +11,8 @@ Currently supported metrics are:
* O₃ concentration (per hour, from [Luchtmeetnet]) * O₃ concentration (per hour, from [Luchtmeetnet])
* Particulate matter (PM10) concentration (per hour, from [Luchtmeetnet]) * Particulate matter (PM10) concentration (per hour, from [Luchtmeetnet])
* Pollen (per hour, from [Buienradar]) * Pollen (per hour, from [Buienradar])
* Pollen/air quality index (per hour, from [Buienradar]) * Pollen/air quality index (per hour, combined from [Buienradar] and
[Luchtmeetnet])
* Precipitation (per 5 minutes, from [Buienradar]) * Precipitation (per 5 minutes, from [Buienradar])
* UV index (per day, from [Buienradar]) * UV index (per day, from [Buienradar])
@ -67,7 +68,6 @@ GET /forecast?address=Stationsplein,Utrecht&metrics[]=all
or directly by using its geocoded position: or directly by using its geocoded position:
```http ```http
GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
``` ```
@ -75,8 +75,8 @@ GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
### Metrics ### Metrics
When querying, the metrics need to be selected. It can be one of: `AQI`, `NO2`, When querying, the metrics need to be selected. It can be one of: `AQI`, `NO2`,
`O3`, `PAQI`, `PM10`, `pollen`, `precipitation` or `UVI`. If you use metric `all`, or `O3`, `PAQI`, `PM10`, `pollen`, `precipitation` or `UVI`. If you use metric
`all` is part of the selected metrics, all metrics will be retrieved. `all`, or `all` is part of the selected metrics, all metrics will be retrieved.
Note that the parameter "array" notation as well as the repeated parameter Note that the parameter "array" notation as well as the repeated parameter
notation are supported. For example: notation are supported. For example:
@ -86,7 +86,7 @@ GET /forecast?address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen
GET /forecast?address=Stationsplein,Utrecht&metrics=all GET /forecast?address=Stationsplein,Utrecht&metrics=all
``` ```
### Response ### Forecast responses
The response of the API is a JSON object that contains three fixed fields: The response of the API is a JSON object that contains three fixed fields:
@ -107,27 +107,27 @@ position:
{ {
"lat": 52.0905169, "lat": 52.0905169,
"lon": 5.1109709, "lon": 5.1109709,
"time": 1645800043, "time": 1652188682,
"UVI": [ "UVI": [
{ {
"time": 1645799526, "time": 1652140800,
"value": 1 "value": 4
}, },
{ {
"time": 1645885926, "time": 1652227200,
"value": 2 "value": 4
}, },
{ {
"time": 1645972326, "time": 1652313600,
"value": 3 "value": 4
}, },
{ {
"time": 1646058726, "time": 1652400000,
"value": 2 "value": 4
}, },
{ {
"time": 1646145126, "time": 1652486400,
"value": 1 "value": 5
} }
] ]
} }
@ -136,34 +136,56 @@ position:
#### Combined metric PAQI #### Combined metric PAQI
The PAQI (pollen/air quality index) metric is a special combined metric. The PAQI (pollen/air quality index) metric is a special combined metric.
If selected, it not only merges items from the AQI and pollen metric into If selected, it merges items from the AQI and pollen metric into `PAQI` by
`PAQI` by selecting the maximum value for each hour, but it also yields the selecting the maximum value for each hour:
24-hour maximum forecast item for air quality index in `AQI_max` and for
pollen in `pollen_max` seperately:
``` json ```json
{ {
"lat": 52.0905169, "lat": 52.0905169,
"lon": 5.1109709, "lon": 5.1109709,
"time": 1645800043, "time": 1652189065,
"AQI_max": {
"time": 1652022000,
"value": 6.65
},
"PAQI": [ "PAQI": [
{ {
"time": 1651951457, "time": 1652187600,
"value": 6.04 "value": 6.09
}, },
{ {
"time": 1651955057, "time": 1652191200,
"value": 6.04 "value": 6.09
}, },
... ...
], ]
"pollen_max": { }
"time": 1652034257, ```
"value": 6
#### Errors
If geocoding of an address is requested but fails, a not found error is
returned (HTTP 404). with the following body (this will change in the future):
```json
{
"error": {
"code": 404,
"reason": "Not Found",
"description": "The requested resource could not be found."
}
}
```
If for any specific metric an error occurs, the list with forecast items will
be absent. However, the `errors` field will contain the error message for each
failed metric. For example, say Buienradar is down and precipitation forecast
items can not be retrieved:
```json
{
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1654524574,
...
"errors": {
"precipitation": "HTTP request error: error sending request for url (https://gpsgadget.buienradar.nl/data/raintext?lat=52.09&lon=5.11): error trying to connect: tcp connect error: Connection refused (os error 111)"
} }
} }
``` ```
@ -188,11 +210,37 @@ or directly by using its geocoded position:
GET /map?lat=52.0902&lon=5.1114&metric=pollen GET /map?lat=52.0902&lon=5.1114&metric=pollen
``` ```
### Response ### Map responses
The response is a PNG image with a crosshair drawn on the map. If geocoding of The response is a PNG image with a crosshair drawn on the map. If geocoding of
an address fails or if the position is out of bounds of the map, nothing is an address fails or if the position is out of bounds of the map, nothing is
returned (HTTP 404). returned (HTTP 404). If the maps cannot/have not been downloaded or cached yet,
a service unavailable error is returned (HTTP 503).
## Version API endpoint
The `/version` API endpoint provides information of the current version and
build of the service. This can be used to check if it needs to be updated.
Again, there is no path and no query parameters, just:
```http
GET /version
```
### Version responses
The response uses the JSON format and typically looks like this:
```json
{
"version": "0.2.7",
"timestamp": "2023-05-29T13:34:34.701323159Z",
"git_sha": "bb5962d",
"git_timestamp": "2023-05-29T15:32:17.000000000+02:00"
}
```
(Build and git information in example output may be out of date.)
## License ## License

9
build.rs Normal file
View File

@ -0,0 +1,9 @@
use std::error::Error;
use vergen::EmitBuilder;
fn main() -> Result<(), Box<dyn Error>> {
// Generate the `cargo:` instructions to fill the appropriate environment variables.
EmitBuilder::builder().all_build().all_git().emit()?;
Ok(())
}

View File

@ -3,20 +3,21 @@
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for //! This module is used to construct a [`Forecast`] for the given position by retrieving data for
//! the requested metrics from their providers. //! the requested metrics from their providers.
use std::collections::BTreeMap;
use std::fmt;
use rocket::serde::Serialize; use rocket::serde::Serialize;
use crate::maps::MapsHandle; use crate::maps::MapsHandle;
use crate::position::Position; use crate::position::Position;
use crate::providers;
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample}; use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
use crate::providers::combined::Item as CombinedItem; use crate::providers::combined::Item as CombinedItem;
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem; use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
use crate::{providers, Error};
/// The current forecast for a specific location. /// The current forecast for a specific location.
/// ///
/// Only the metrics asked for are included as well as the position and current time. /// Only the metrics asked for are included as well as the position and current time.
///
// TODO: Fill in missing data (#4)
#[derive(Debug, Default, Serialize)] #[derive(Debug, Default, Serialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub(crate) struct Forecast { pub(crate) struct Forecast {
@ -33,10 +34,6 @@ pub(crate) struct Forecast {
#[serde(rename = "AQI", skip_serializing_if = "Option::is_none")] #[serde(rename = "AQI", skip_serializing_if = "Option::is_none")]
aqi: Option<Vec<LuchtmeetnetItem>>, aqi: Option<Vec<LuchtmeetnetItem>>,
/// The maximum air quality index value (when asked for PAQI).
#[serde(rename = "AQI_max", skip_serializing_if = "Option::is_none")]
aqi_max: Option<LuchtmeetnetItem>,
/// The NO₂ concentration (when asked for). /// The NO₂ concentration (when asked for).
#[serde(rename = "NO2", skip_serializing_if = "Option::is_none")] #[serde(rename = "NO2", skip_serializing_if = "Option::is_none")]
no2: Option<Vec<LuchtmeetnetItem>>, no2: Option<Vec<LuchtmeetnetItem>>,
@ -57,10 +54,6 @@ pub(crate) struct Forecast {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pollen: Option<Vec<BuienradarSample>>, pollen: Option<Vec<BuienradarSample>>,
/// The maximum pollen in the air (when asked for PAQI).
#[serde(skip_serializing_if = "Option::is_none")]
pollen_max: Option<BuienradarSample>,
/// The precipitation (when asked for). /// The precipitation (when asked for).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
precipitation: Option<Vec<BuienradarItem>>, precipitation: Option<Vec<BuienradarItem>>,
@ -68,6 +61,10 @@ pub(crate) struct Forecast {
/// The UV index (when asked for). /// The UV index (when asked for).
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")] #[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
uvi: Option<Vec<BuienradarSample>>, uvi: Option<Vec<BuienradarSample>>,
/// Any errors that occurred.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
errors: BTreeMap<Metric, String>,
} }
impl Forecast { impl Forecast {
@ -80,13 +77,21 @@ impl Forecast {
..Default::default() ..Default::default()
} }
} }
fn log_error(&mut self, metric: Metric, error: Error) {
eprintln!("💥 Encountered error during forecast: {}", error);
self.errors.insert(metric, error.to_string());
}
} }
/// The supported forecast metrics. /// The supported forecast metrics.
/// ///
/// This is used for selecting which metrics should be calculated & returned. /// This is used for selecting which metrics should be calculated & returned.
#[allow(clippy::upper_case_acronyms)] #[allow(clippy::upper_case_acronyms)]
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, rocket::FromFormField)] #[derive(
Copy, Clone, Debug, Eq, Hash, Ord, PartialOrd, PartialEq, Serialize, rocket::FromFormField,
)]
#[serde(crate = "rocket::serde")]
pub(crate) enum Metric { pub(crate) enum Metric {
/// All metrics. /// All metrics.
#[field(value = "all")] #[field(value = "all")]
@ -102,7 +107,9 @@ pub(crate) enum Metric {
/// The particulate matter in the air. /// The particulate matter in the air.
PM10, PM10,
/// The pollen in the air. /// The pollen in the air.
#[serde(rename(serialize = "pollen"))]
Pollen, Pollen,
#[serde(rename(serialize = "precipitation"))]
/// The precipitation. /// The precipitation.
Precipitation, Precipitation,
/// The UV index. /// The UV index.
@ -118,6 +125,22 @@ impl Metric {
} }
} }
impl fmt::Display for Metric {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Metric::All => write!(f, "All"),
Metric::AQI => write!(f, "AQI"),
Metric::NO2 => write!(f, "NO2"),
Metric::O3 => write!(f, "O3"),
Metric::PAQI => write!(f, "PAQI"),
Metric::PM10 => write!(f, "PM10"),
Metric::Pollen => write!(f, "pollen"),
Metric::Precipitation => write!(f, "precipitation"),
Metric::UVI => write!(f, "UVI"),
}
}
}
/// Calculates and returns the forecast. /// Calculates and returns the forecast.
/// ///
/// The provided list `metrics` determines what will be included in the forecast. /// The provided list `metrics` determines what will be included in the forecast.
@ -140,29 +163,53 @@ pub(crate) async fn forecast(
match metric { match metric {
// This should have been expanded to all the metrics matched below. // This should have been expanded to all the metrics matched below.
Metric::All => unreachable!("The all metric should have been expanded"), Metric::All => unreachable!("The all metric should have been expanded"),
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await, Metric::AQI => {
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await, forecast.aqi = providers::luchtmeetnet::get(position, metric)
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await, .await
Metric::PAQI => { .map_err(|err| forecast.log_error(metric, err))
if let Some((paqi, pollen_max, aqi_max)) = .ok()
providers::combined::get(position, metric, maps_handle).await }
{ Metric::NO2 => {
forecast.paqi = Some(paqi); forecast.no2 = providers::luchtmeetnet::get(position, metric)
forecast.aqi_max = Some(aqi_max); .await
forecast.pollen_max = Some(pollen_max); .map_err(|err| forecast.log_error(metric, err))
} .ok()
}
Metric::O3 => {
forecast.o3 = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::PAQI => {
forecast.paqi = providers::combined::get(position, metric, maps_handle)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::PM10 => {
forecast.pm10 = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
} }
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
Metric::Pollen => { Metric::Pollen => {
forecast.pollen = forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
providers::buienradar::get_samples(position, metric, maps_handle).await .await
.map_err(|err| forecast.log_error(metric, err))
.ok()
} }
Metric::Precipitation => { Metric::Precipitation => {
forecast.precipitation = providers::buienradar::get_items(position, metric).await forecast.precipitation = providers::buienradar::get_items(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
} }
Metric::UVI => { Metric::UVI => {
forecast.uvi = forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
providers::buienradar::get_samples(position, metric, maps_handle).await .await
.map_err(|err| forecast.log_error(metric, err))
.ok()
} }
} }
} }

View File

@ -1,22 +1,31 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![warn( #![warn(
clippy::all, clippy::all,
missing_copy_implementations,
missing_debug_implementations, missing_debug_implementations,
rust_2018_idioms, rust_2018_idioms,
rustdoc::broken_intra_doc_links rustdoc::broken_intra_doc_links,
trivial_casts,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)] )]
#![deny(missing_docs)] #![deny(missing_docs)]
use std::future::Future;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rocket::fairing::AdHoc;
use rocket::http::Status;
use rocket::response::Responder; use rocket::response::Responder;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::{get, routes, Build, Rocket, State}; use rocket::serde::Serialize;
use rocket::{get, routes, Build, Request, Rocket, State};
pub(crate) use self::forecast::Metric; use self::forecast::{forecast, Forecast, Metric};
use self::forecast::{forecast, Forecast}; use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle};
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
use self::position::{resolve_address, Position}; use self::position::{resolve_address, Position};
pub(crate) mod forecast; pub(crate) mod forecast;
@ -24,21 +33,104 @@ pub(crate) mod maps;
pub(crate) mod position; pub(crate) mod position;
pub(crate) mod providers; pub(crate) mod providers;
/// The possible provider errors that can occur.
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
/// A CSV parse error occurred.
#[error("CSV parse error: {0}")]
CsvParse(#[from] csv::Error),
/// A geocoding error occurred.
#[error("Geocoding error: {0}")]
Geocoding(#[from] geocoding::GeocodingError),
/// An HTTP request error occurred.
#[error("HTTP request error: {0}")]
HttpRequest(#[from] reqwest::Error),
/// Failed to join a task.
#[error("Failed to join a task: {0}")]
Join(#[from] rocket::tokio::task::JoinError),
/// Failed to merge AQI & pollen items.
#[error("Failed to merge AQI & pollen items: {0}")]
Merge(#[from] providers::combined::MergeError),
/// Failed to retrieve or sample the maps.
#[error("Failed to retrieve or sample the maps: {0}")]
Maps(#[from] maps::Error),
/// No geocoded position could be found.
#[error("No geocoded position could be found")]
NoPositionFound,
/// Encountered an unsupported metric.
#[error("Encountered an unsupported metric: {0}")]
UnsupportedMetric(Metric),
}
impl<'r, 'o: 'r> Responder<'r, 'o> for Error {
fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> {
eprintln!("💥 Encountered error during request: {}", self);
let status = match self {
Error::NoPositionFound => Status::NotFound,
Error::Maps(MapsError::NoMapsYet) => Status::ServiceUnavailable,
Error::Maps(MapsError::OutOfBoundCoords(_, _)) => Status::NotFound,
Error::Maps(MapsError::OutOfBoundOffset(_)) => Status::NotFound,
_ => Status::InternalServerError,
};
Err(status)
}
}
#[derive(Responder)] #[derive(Responder)]
#[response(status = 200, content_type = "image/png")] #[response(content_type = "image/png")]
struct PngImageData(Vec<u8>); struct PngImageData(Vec<u8>);
/// Result type that defaults to [`Error`] as the default error type.
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
/// The version information as JSON response.
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]
struct VersionInfo {
/// The version of the build.
version: String,
/// The timestamp of the build.
timestamp: String,
/// The (most recent) git SHA used for the build.
git_sha: String,
/// The timestamp of the last git commit used for the build.
git_timestamp: String,
}
impl VersionInfo {
/// Retrieves the version information from the environment variables.
fn new() -> Self {
Self {
version: String::from(env!("CARGO_PKG_VERSION")),
timestamp: String::from(env!("VERGEN_BUILD_TIMESTAMP")),
git_sha: String::from(&env!("VERGEN_GIT_SHA")[0..7]),
git_timestamp: String::from(env!("VERGEN_GIT_COMMIT_TIMESTAMP")),
}
}
}
/// Handler for retrieving the forecast for an address. /// Handler for retrieving the forecast for an address.
#[get("/forecast?<address>&<metrics>")] #[get("/forecast?<address>&<metrics>")]
async fn forecast_address( async fn forecast_address(
address: String, address: String,
metrics: Vec<Metric>, metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> { ) -> Result<Json<Forecast>> {
let position = resolve_address(address).await?; let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await; let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast)) Ok(Json(forecast))
} }
/// Handler for retrieving the forecast for a geocoded position. /// Handler for retrieving the forecast for a geocoded position.
@ -64,7 +156,7 @@ async fn map_address(
address: String, address: String,
metric: Metric, metric: Metric,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> { ) -> Result<PngImageData> {
let position = resolve_address(address).await?; let position = resolve_address(address).await?;
let image_data = mark_map(position, metric, maps_handle).await; let image_data = mark_map(position, metric, maps_handle).await;
@ -80,29 +172,64 @@ async fn map_geo(
lon: f64, lon: f64,
metric: Metric, metric: Metric,
maps_handle: &State<MapsHandle>, maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> { ) -> Result<PngImageData> {
let position = Position::new(lat, lon); let position = Position::new(lat, lon);
let image_data = mark_map(position, metric, maps_handle).await; let image_data = mark_map(position, metric, maps_handle).await;
image_data.map(PngImageData) image_data.map(PngImageData)
} }
/// Returns the version information.
#[get("/version", format = "application/json")]
async fn version() -> Result<Json<VersionInfo>> {
Ok(Json(VersionInfo::new()))
}
/// Sets up Rocket without fairings.
fn rocket_core(maps_handle: MapsHandle) -> Rocket<Build> {
rocket::build()
.mount(
"/",
routes![
forecast_address,
forecast_geo,
map_address,
map_geo,
version
],
)
.manage(maps_handle)
}
/// Sets up Rocket. /// Sets up Rocket.
fn rocket(maps_handle: MapsHandle) -> Rocket<Build> { fn rocket(maps_handle: MapsHandle) -> Rocket<Build> {
rocket::build().manage(maps_handle).mount( let rocket = rocket_core(Arc::clone(&maps_handle));
"/", let maps_refresher = maps::run(maps_handle);
routes![forecast_address, forecast_geo, map_address, map_geo],
) rocket
.attach(AdHoc::on_liftoff("Maps refresher", |_| {
Box::pin(async move {
// We don't care about the join handle nor error results?
let _refresher = rocket::tokio::spawn(maps_refresher);
})
}))
.attach(AdHoc::on_liftoff("Version", |_| {
Box::pin(async move {
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
let git_sha = &env!("VERGEN_GIT_SHA")[0..7];
println!("🌁 Started {name} v{version} (git @{git_sha})");
})
}))
} }
/// Sets up Rocket and the maps cache refresher task. /// Sets up Rocket and the maps cache refresher task.
pub fn setup() -> (Rocket<Build>, impl Future<Output = ()>) { pub fn setup() -> Rocket<Build> {
let maps = Maps::new(); let maps = Maps::new();
let maps_handle = Arc::new(Mutex::new(maps)); let maps_handle = Arc::new(Mutex::new(maps));
let maps_refresher = maps::run(Arc::clone(&maps_handle));
let rocket = rocket(maps_handle);
(rocket, maps_refresher) rocket(maps_handle)
} }
#[cfg(test)] #[cfg(test)]
@ -110,7 +237,7 @@ mod tests {
use assert_float_eq::*; use assert_float_eq::*;
use assert_matches::assert_matches; use assert_matches::assert_matches;
use image::{DynamicImage, Rgba, RgbaImage}; use image::{DynamicImage, Rgba, RgbaImage};
use rocket::http::Status; use rocket::http::{ContentType, Status};
use rocket::local::blocking::Client; use rocket::local::blocking::Client;
use rocket::serde::json::Value as JsonValue; use rocket::serde::json::Value as JsonValue;
@ -142,17 +269,15 @@ mod tests {
let response = client.get("/forecast?address=eindhoven").dispatch(); let response = client.get("/forecast?address=eindhoven").dispatch();
assert_eq!(response.status(), Status::Ok); assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON"); let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); assert_float_absolute_eq!(json["lat"].as_f64().unwrap(), 51.448557, 1e-1);
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); assert_float_absolute_eq!(json["lon"].as_f64().unwrap(), 5.450123, 1e-1);
assert_matches!(json["time"], JsonValue::Number(_)); assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), None); assert_matches!(json.get("AQI"), None);
assert_matches!(json.get("AQI_max"), None);
assert_matches!(json.get("NO2"), None); assert_matches!(json.get("NO2"), None);
assert_matches!(json.get("O3"), None); assert_matches!(json.get("O3"), None);
assert_matches!(json.get("PAQI"), None); assert_matches!(json.get("PAQI"), None);
assert_matches!(json.get("PM10"), None); assert_matches!(json.get("PM10"), None);
assert_matches!(json.get("pollen"), None); assert_matches!(json.get("pollen"), None);
assert_matches!(json.get("pollen_max"), None);
assert_matches!(json.get("precipitation"), None); assert_matches!(json.get("precipitation"), None);
assert_matches!(json.get("UVI"), None); assert_matches!(json.get("UVI"), None);
@ -162,17 +287,15 @@ mod tests {
.dispatch(); .dispatch();
assert_eq!(response.status(), Status::Ok); assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON"); let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); assert_float_absolute_eq!(json["lat"].as_f64().unwrap(), 51.448557, 1e-1);
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); assert_float_absolute_eq!(json["lon"].as_f64().unwrap(), 5.450123, 1e-1);
assert_matches!(json["time"], JsonValue::Number(_)); assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
} }
@ -190,13 +313,11 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
assert_matches!(json["time"], JsonValue::Number(_)); assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), None); assert_matches!(json.get("AQI"), None);
assert_matches!(json.get("AQI_max"), None);
assert_matches!(json.get("NO2"), None); assert_matches!(json.get("NO2"), None);
assert_matches!(json.get("O3"), None); assert_matches!(json.get("O3"), None);
assert_matches!(json.get("PAQI"), None); assert_matches!(json.get("PAQI"), None);
assert_matches!(json.get("PM10"), None); assert_matches!(json.get("PM10"), None);
assert_matches!(json.get("pollen"), None); assert_matches!(json.get("pollen"), None);
assert_matches!(json.get("pollen_max"), None);
assert_matches!(json.get("precipitation"), None); assert_matches!(json.get("precipitation"), None);
assert_matches!(json.get("UVI"), None); assert_matches!(json.get("UVI"), None);
@ -210,13 +331,11 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
assert_matches!(json["time"], JsonValue::Number(_)); assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
} }
@ -225,13 +344,14 @@ mod tests {
fn map_address() { fn map_address() {
let maps_handle = Arc::new(Mutex::new(Maps::new())); let maps_handle = Arc::new(Mutex::new(Maps::new()));
let maps_handle_clone = Arc::clone(&maps_handle); let maps_handle_clone = Arc::clone(&maps_handle);
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); let client =
Client::tracked(rocket_core(maps_handle)).expect("Not a valid Rocket instance");
// No maps available yet. // No maps available yet.
let response = client let response = client
.get("/map?address=eindhoven&metric=pollen") .get("/map?address=eindhoven&metric=pollen")
.dispatch(); .dispatch();
assert_eq!(response.status(), Status::NotFound); assert_eq!(response.status(), Status::ServiceUnavailable);
// Load some dummy map. // Load some dummy map.
let mut maps = maps_handle_clone let mut maps = maps_handle_clone
@ -253,22 +373,19 @@ mod tests {
// No metric selected, don't know which map to show? // No metric selected, don't know which map to show?
let response = client.get("/map?address=eindhoven").dispatch(); let response = client.get("/map?address=eindhoven").dispatch();
assert_eq!(response.status(), Status::NotFound); assert_eq!(response.status(), Status::UnprocessableEntity);
} }
#[test] #[test]
fn map_geo() { fn map_geo() {
let maps_handle = Arc::new(Mutex::new(Maps::new())); let maps_handle = Arc::new(Mutex::new(Maps::new()));
let maps_handle_clone = Arc::clone(&maps_handle); let maps_handle_clone = Arc::clone(&maps_handle);
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); let client =
Client::tracked(rocket_core(maps_handle)).expect("Not a valid Rocket instance");
// No metric passed, don't know which map to show?
let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
assert_eq!(response.status(), Status::NotFound);
// No maps available yet. // No maps available yet.
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
assert_eq!(response.status(), Status::NotFound); assert_eq!(response.status(), Status::ServiceUnavailable);
// Load some dummy map. // Load some dummy map.
let mut maps = maps_handle_clone let mut maps = maps_handle_clone
@ -282,8 +399,12 @@ mod tests {
assert_eq!(response.status(), Status::Ok); assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::PNG)); assert_eq!(response.content_type(), Some(ContentType::PNG));
// ... but not if it is out of bounds.
let response = client.get("/map?lat=0.0&lon=0.0&metric=pollen").dispatch();
assert_eq!(response.status(), Status::NotFound);
// No metric passed, don't know which map to show? // No metric passed, don't know which map to show?
let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
assert_eq!(response.status(), Status::NotFound); assert_eq!(response.status(), Status::UnprocessableEntity);
} }
} }

View File

@ -1,34 +1,22 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
#![warn( #![warn(
clippy::all, clippy::all,
missing_copy_implementations,
missing_debug_implementations, missing_debug_implementations,
rust_2018_idioms, rust_2018_idioms,
rustdoc::broken_intra_doc_links rustdoc::broken_intra_doc_links,
trivial_casts,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)] )]
#![deny(missing_docs)] #![deny(missing_docs)]
use color_eyre::Result;
use rocket::tokio::{self, select};
/// Starts the main maps refresh task and sets up and launches Rocket. /// Starts the main maps refresh task and sets up and launches Rocket.
#[rocket::main] #[rocket::launch]
async fn main() -> Result<()> { async fn rocket() -> _ {
color_eyre::install()?; sinoptik::setup()
let (rocket, maps_refresher) = sinoptik::setup();
let rocket = rocket.ignite().await?;
let shutdown = rocket.shutdown();
let maps_refresher = tokio::spawn(maps_refresher);
select! {
result = rocket.launch() => {
result.map(|_| ())?
}
result = maps_refresher => {
shutdown.notify();
result?
}
}
Ok(())
} }

View File

@ -8,8 +8,10 @@ use std::f64::consts::PI;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use chrono::serde::ts_seconds; use chrono::serde::ts_seconds;
use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgb, Rgba}; use image::{
DynamicImage, GenericImage, GenericImageView, ImageError, ImageFormat, Pixel, Rgb, Rgba,
};
use reqwest::Url; use reqwest::Url;
use rocket::serde::Serialize; use rocket::serde::Serialize;
use rocket::tokio; use rocket::tokio;
@ -18,6 +20,53 @@ use rocket::tokio::time::sleep;
use crate::forecast::Metric; use crate::forecast::Metric;
use crate::position::Position; use crate::position::Position;
/// The possible maps errors that can occur.
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
/// A timestamp parse error occurred.
#[error("Timestamp parse error: {0}")]
ChronoParse(#[from] chrono::ParseError),
/// A HTTP request error occurred.
#[error("HTTP request error: {0}")]
HttpRequest(#[from] reqwest::Error),
/// Failed to represent HTTP header as a string.
#[error("Failed to represent HTTP header as a string")]
HttpHeaderToStr(#[from] reqwest::header::ToStrError),
/// An image error occurred.
#[error("Image error: {0}")]
Image(#[from] ImageError),
/// Encountered an invalid image file path.
#[error("Invalid image file path: {0}")]
InvalidImagePath(String),
/// Failed to join a task.
#[error("Failed to join a task: {0}")]
Join(#[from] tokio::task::JoinError),
/// Did not find any known (map key) colors in samples.
#[error("Did not find any known colors in samples")]
NoKnownColorsInSamples,
/// No maps found (yet).
#[error("No maps found (yet)")]
NoMapsYet,
/// Got out of bound coordinates for a map.
#[error("Got out of bound coordinates for a map: ({0}, {1})")]
OutOfBoundCoords(u32, u32),
/// Got out of bound offset for a map.
#[error("Got out of bound offset for a map: {0}")]
OutOfBoundOffset(u32),
}
/// Result type that defaults to [`Error`] as the default error type.
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
/// A handle to access the in-memory cached maps. /// A handle to access the in-memory cached maps.
pub(crate) type MapsHandle = Arc<Mutex<Maps>>; pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
@ -29,22 +78,22 @@ type MapKeyHistogram = HashMap<Rgb<u8>, u32>;
/// Note that the actual score starts from 1, not 0 as per this array. /// Note that the actual score starts from 1, not 0 as per this array.
#[rustfmt::skip] #[rustfmt::skip]
const MAP_KEY: [[u8; 3]; 10] = [ const MAP_KEY: [[u8; 3]; 10] = [
[ 73, 218, 33], [0x49, 0xDA, 0x21], // #49DA21
[ 48, 210, 0], [0x30, 0xD2, 0x00], // #30D200
[255, 248, 139], [0xFF, 0xF8, 0x8B], // #FFF88B
[255, 246, 66], [0xFF, 0xF6, 0x42], // #FFF642
[253, 187, 49], [0xFD, 0xBB, 0x31], // #FDBB31
[253, 142, 36], [0xFD, 0x8E, 0x24], // #FD8E24
[252, 16, 62], [0xFC, 0x10, 0x3E], // #FC103E
[150, 10, 51], [0x97, 0x0A, 0x33], // #970A33
[166, 109, 188], [0xA6, 0x6D, 0xBC], // #A66DBC
[179, 48, 161], [0xB3, 0x30, 0xA1], // #B330A1
]; ];
/// The Buienradar map sample size. /// The Buienradar map sample size.
/// ///
/// Determiess the number of pixels in width/height that is samples around the sampling coordinate. /// Determines the number of pixels in width/height that is sampled around the sampling coordinate.
const MAP_SAMPLE_SIZE: [u32; 2] = [11, 11]; const MAP_SAMPLE_SIZE: [u32; 2] = [31, 31];
/// The interval between map refreshes (in seconds). /// The interval between map refreshes (in seconds).
const REFRESH_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_secs(60); const REFRESH_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_secs(60);
@ -113,10 +162,10 @@ trait MapsRefresh {
fn is_uvi_stale(&self) -> bool; fn is_uvi_stale(&self) -> bool;
/// Updates the pollen maps. /// Updates the pollen maps.
fn set_pollen(&self, result: Option<RetrievedMaps>); fn set_pollen(&self, result: Result<RetrievedMaps>);
/// Updates the UV index maps. /// Updates the UV index maps.
fn set_uvi(&self, result: Option<RetrievedMaps>); fn set_uvi(&self, result: Result<RetrievedMaps>);
} }
/// Container type for all in-memory cached maps. /// Container type for all in-memory cached maps.
@ -142,71 +191,53 @@ impl Maps {
} }
/// Returns a current pollen map that marks the provided position. /// Returns a current pollen map that marks the provided position.
/// pub(crate) fn pollen_mark(&self, position: Position) -> Result<DynamicImage> {
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
/// the current moment or if the provided position is not within the bounds of the map. let image = &maps.image;
pub(crate) fn pollen_mark(&self, position: Position) -> Option<DynamicImage> { let stamp = maps.timestamp_base;
self.pollen.as_ref().and_then(|maps| { let marked_image = map_at(
let image = &maps.image; image,
let stamp = maps.timestamp_base; stamp,
let marked_image = map_at( POLLEN_MAP_INTERVAL,
image, POLLEN_MAP_COUNT,
stamp, Utc::now(),
POLLEN_MAP_INTERVAL, )?;
POLLEN_MAP_COUNT, let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
Utc::now(),
)?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
Some(mark(marked_image, coords)) Ok(mark(marked_image, coords))
})
} }
/// Samples the pollen maps for the given position. /// Samples the pollen maps for the given position.
/// pub(crate) fn pollen_samples(&self, position: Position) -> Result<Vec<Sample>> {
/// This returns [`None`] if the maps are not in the cache yet. let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
/// Otherwise, it returns [`Some`] with a list of pollen sample, one for each map let image = &maps.image;
/// in the series of maps. let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
pub(crate) fn pollen_samples(&self, position: Position) -> Option<Vec<Sample>> { let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
self.pollen.as_ref().and_then(|maps| { let stamp = maps.timestamp_base;
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords) sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords)
})
} }
/// Returns a current UV index map that marks the provided position. /// Returns a current UV index map that marks the provided position.
/// pub(crate) fn uvi_mark(&self, position: Position) -> Result<DynamicImage> {
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
/// the current moment or if the provided position is not within the bounds of the map. let image = &maps.image;
pub(crate) fn uvi_mark(&self, position: Position) -> Option<DynamicImage> { let stamp = maps.timestamp_base;
self.uvi.as_ref().and_then(|maps| { let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?;
let image = &maps.image; let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
Some(mark(marked_image, coords)) Ok(mark(marked_image, coords))
})
} }
/// Samples the UV index maps for the given position. /// Samples the UV index maps for the given position.
/// pub(crate) fn uvi_samples(&self, position: Position) -> Result<Vec<Sample>> {
/// This returns [`None`] if the maps are not in the cache yet. let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
/// Otherwise, it returns [`Some`] with a list of UV index sample, one for each map let image = &maps.image;
/// in the series of maps. let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
pub(crate) fn uvi_samples(&self, position: Position) -> Option<Vec<Sample>> { let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
self.uvi.as_ref().and_then(|maps| { let stamp = maps.timestamp_base;
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords) sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords)
})
} }
} }
@ -263,17 +294,17 @@ impl MapsRefresh for MapsHandle {
} }
} }
fn set_pollen(&self, retrieved_maps: Option<RetrievedMaps>) { fn set_pollen(&self, retrieved_maps: Result<RetrievedMaps>) {
if retrieved_maps.is_some() || self.is_pollen_stale() { if retrieved_maps.is_ok() || self.is_pollen_stale() {
let mut maps = self.lock().expect("Maps handle mutex was poisoned"); let mut maps = self.lock().expect("Maps handle mutex was poisoned");
maps.pollen = retrieved_maps; maps.pollen = retrieved_maps.ok();
} }
} }
fn set_uvi(&self, retrieved_maps: Option<RetrievedMaps>) { fn set_uvi(&self, retrieved_maps: Result<RetrievedMaps>) {
if retrieved_maps.is_some() || self.is_uvi_stale() { if retrieved_maps.is_ok() || self.is_uvi_stale() {
let mut maps = self.lock().expect("Maps handle mutex was poisoned"); let mut maps = self.lock().expect("Maps handle mutex was poisoned");
maps.uvi = retrieved_maps; maps.uvi = retrieved_maps.ok();
} }
} }
} }
@ -315,18 +346,19 @@ fn map_key_histogram() -> MapKeyHistogram {
/// Samples the provided maps at the given (map-relative) coordinates and starting timestamp. /// Samples the provided maps at the given (map-relative) coordinates and starting timestamp.
/// It assumes the provided coordinates are within bounds of at least one map. /// It assumes the provided coordinates are within bounds of at least one map.
/// The interval is the number of seconds the timestamp is bumped for each map. /// The interval is the number of seconds the timestamp is bumped for each map.
///
/// Returns [`None`] if it encounters no known colors in any of the samples.
fn sample<I: GenericImageView<Pixel = Rgba<u8>>>( fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
image: &I, image: &I,
stamp: DateTime<Utc>, stamp: DateTime<Utc>,
interval: i64, interval: i64,
count: u32, count: u32,
coords: (u32, u32), coords: (u32, u32),
) -> Option<Vec<Sample>> { ) -> Result<Vec<Sample>> {
let (x, y) = coords; let (x, y) = coords;
let width = image.width() / count; let width = image.width() / count;
let height = image.height(); let height = image.height();
if x > width || y > height {
return Err(Error::OutOfBoundCoords(x, y));
}
let max_sample_width = (width - x).min(MAP_SAMPLE_SIZE[0]); let max_sample_width = (width - x).min(MAP_SAMPLE_SIZE[0]);
let max_sample_height = (height - y).min(MAP_SAMPLE_SIZE[1]); let max_sample_height = (height - y).min(MAP_SAMPLE_SIZE[1]);
let mut samples = Vec::with_capacity(count as usize); let mut samples = Vec::with_capacity(count as usize);
@ -351,7 +383,7 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
.max_by_key(|(_color, count)| *count) .max_by_key(|(_color, count)| *count)
.expect("Map key is never empty"); .expect("Map key is never empty");
if count == 0 { if count == 0 {
return None; return Err(Error::NoKnownColorsInSamples);
} }
let score = MAP_KEY let score = MAP_KEY
@ -361,11 +393,11 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
.expect("Maximum color is always a map key color") as u8; .expect("Maximum color is always a map key color") as u8;
samples.push(Sample { time, score }); samples.push(Sample { time, score });
time = time + chrono::Duration::seconds(interval as i64); time += Duration::seconds(interval);
offset += width; offset += width;
} }
Some(samples) Ok(samples)
} }
/// A retrieved image with some metadata. /// A retrieved image with some metadata.
@ -396,89 +428,84 @@ impl RetrievedMaps {
} }
/// Retrieves an image from the provided URL. /// Retrieves an image from the provided URL.
/// async fn retrieve_image(url: Url) -> Result<RetrievedMaps> {
/// This returns [`None`] if it fails in either performing the request, parsing the `Last-Modified` let response = reqwest::get(url).await?;
/// reponse HTTP header, retrieving the bytes from the image or loading and the decoding the data let mtime = match response.headers().get(reqwest::header::LAST_MODIFIED) {
/// into [`DynamicImage`]. Some(mtime_header) => {
async fn retrieve_image(url: Url) -> Option<RetrievedMaps> { let mtime_headr_str = mtime_header.to_str()?;
// TODO: Handle or log errors!
let response = reqwest::get(url).await.ok()?; DateTime::from(DateTime::parse_from_rfc2822(mtime_headr_str)?)
let mtime = response }
.headers() None => Utc::now(),
.get(reqwest::header::LAST_MODIFIED) };
.and_then(|dt| dt.to_str().ok())
.map(chrono::DateTime::parse_from_rfc2822)?
.map(DateTime::<Utc>::from)
.ok()?;
let timestamp_base = { let timestamp_base = {
let path = response.url().path(); let path = response.url().path();
let (_, filename) = path.rsplit_once('/')?; let (_, filename) = path
let (timestamp_str, _) = filename.split_once("__")?; .rsplit_once('/')
let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M").ok()?; .ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?;
let (timestamp_str, _) = filename
.split_once("__")
.ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?;
let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M")?;
DateTime::<Utc>::from_utc(timestamp, Utc) Utc.from_utc_datetime(&timestamp)
}; };
let bytes = response.bytes().await.ok()?; let bytes = response.bytes().await?;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Ok(image) = image::load_from_memory_with_format(&bytes, ImageFormat::Png) { image::load_from_memory_with_format(&bytes, ImageFormat::Png)
Some(RetrievedMaps { .map(|image| RetrievedMaps {
image, image,
mtime, mtime,
timestamp_base, timestamp_base,
}) })
} else { .map_err(Error::from)
None
}
}) })
.await .await?
.ok()?
} }
/// Retrieves the pollen maps from Buienradar. /// Retrieves the pollen maps from Buienradar.
/// ///
/// See [`POLLEN_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function. /// See [`POLLEN_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
async fn retrieve_pollen_maps() -> Option<RetrievedMaps> { async fn retrieve_pollen_maps() -> Result<RetrievedMaps> {
let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M")); let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
let mut url = Url::parse(POLLEN_BASE_URL).unwrap(); let mut url = Url::parse(POLLEN_BASE_URL).unwrap();
url.query_pairs_mut().append_pair("timestamp", &timestamp); url.query_pairs_mut().append_pair("timestamp", &timestamp);
println!("🔽 Refreshing pollen maps from: {}", url); println!("🗺️ Refreshing pollen maps from: {}", url);
retrieve_image(url).await retrieve_image(url).await
} }
/// Retrieves the UV index maps from Buienradar. /// Retrieves the UV index maps from Buienradar.
/// ///
/// See [`UVI_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function. /// See [`UVI_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
async fn retrieve_uvi_maps() -> Option<RetrievedMaps> { async fn retrieve_uvi_maps() -> Result<RetrievedMaps> {
let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M")); let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
let mut url = Url::parse(UVI_BASE_URL).unwrap(); let mut url = Url::parse(UVI_BASE_URL).unwrap();
url.query_pairs_mut().append_pair("timestamp", &timestamp); url.query_pairs_mut().append_pair("timestamp", &timestamp);
println!("🔽 Refreshing UV index maps from: {}", url); println!("🗺️ Refreshing UV index maps from: {}", url);
retrieve_image(url).await retrieve_image(url).await
} }
/// Returns the map for the given instant. /// Returns the map for the given instant.
///
/// This returns [`None`] if `instant` is too far in the future with respect to the number of
/// cached maps.
fn map_at( fn map_at(
image: &DynamicImage, image: &DynamicImage,
stamp: DateTime<Utc>, stamp: DateTime<Utc>,
interval: i64, interval: i64,
count: u32, count: u32,
instant: DateTime<Utc>, instant: DateTime<Utc>,
) -> Option<DynamicImage> { ) -> Result<DynamicImage> {
let duration = instant.signed_duration_since(stamp); let duration = instant.signed_duration_since(stamp);
let offset = (duration.num_seconds() / interval) as u32; let offset = (duration.num_seconds() / interval) as u32;
// Check if out of bounds. // Check if out of bounds.
if offset >= count { if offset >= count {
return None; return Err(Error::OutOfBoundOffset(offset));
} }
let width = image.width() / count; let width = image.width() / count;
Some(image.crop_imm(offset * width, 0, width, image.height())) Ok(image.crop_imm(offset * width, 0, width, image.height()))
} }
/// Marks the provided coordinates on the map using a horizontal and vertical line. /// Marks the provided coordinates on the map using a horizontal and vertical line.
@ -499,13 +526,11 @@ fn mark(mut image: DynamicImage, coords: (u32, u32)) -> DynamicImage {
/// ///
/// This uses two reference points and a Mercator projection on the y-coordinates of those points /// This uses two reference points and a Mercator projection on the y-coordinates of those points
/// to calculate how the map scales with respect to the provided position. /// to calculate how the map scales with respect to the provided position.
///
/// Returns [`None`] if the resulting coordinate is not within the bounds of the map.
fn project<I: GenericImageView>( fn project<I: GenericImageView>(
image: &I, image: &I,
ref_points: [(Position, (u32, u32)); 2], ref_points: [(Position, (u32, u32)); 2],
pos: Position, pos: Position,
) -> Option<(u32, u32)> { ) -> Result<(u32, u32)> {
// Get the data from the reference points. // Get the data from the reference points.
let (ref1, (ref1_y, ref1_x)) = ref_points[0]; let (ref1, (ref1_y, ref1_x)) = ref_points[0];
let (ref2, (ref2_y, ref2_x)) = ref_points[1]; let (ref2, (ref2_y, ref2_x)) = ref_points[1];
@ -522,9 +547,9 @@ fn project<I: GenericImageView>(
let y = ((ref2_merc_y - mercator_y(pos.lat_as_rad())) * scale_y + ref2_y as f64).round() as u32; let y = ((ref2_merc_y - mercator_y(pos.lat_as_rad())) * scale_y + ref2_y as f64).round() as u32;
if image.in_bounds(x, y) { if image.in_bounds(x, y) {
Some((x, y)) Ok((x, y))
} else { } else {
None Err(Error::OutOfBoundCoords(x, y))
} }
} }
@ -535,34 +560,28 @@ pub(crate) async fn mark_map(
position: Position, position: Position,
metric: Metric, metric: Metric,
maps_handle: &MapsHandle, maps_handle: &MapsHandle,
) -> Option<Vec<u8>> { ) -> crate::Result<Vec<u8>> {
use std::io::Cursor; use std::io::Cursor;
let maps_handle = Arc::clone(maps_handle); let maps_handle = Arc::clone(maps_handle);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let maps = maps_handle.lock().expect("Maps handle lock was poisoned"); let maps = maps_handle.lock().expect("Maps handle lock was poisoned");
let image = match metric { let image = match metric {
Metric::PAQI => maps.pollen_mark(position),
Metric::Pollen => maps.pollen_mark(position), Metric::Pollen => maps.pollen_mark(position),
Metric::UVI => maps.uvi_mark(position), Metric::UVI => maps.uvi_mark(position),
_ => return None, // Unsupported metric _ => return Err(crate::Error::UnsupportedMetric(metric)),
}?; }?;
drop(maps); drop(maps);
// Encode the image as PNG image data. // Encode the image as PNG image data.
let mut image_data = Cursor::new(Vec::new()); let mut image_data = Cursor::new(Vec::new());
image match image.write_to(&mut image_data, ImageFormat::Png) {
.write_to( Ok(()) => Ok(image_data.into_inner()),
&mut image_data, Err(err) => Err(crate::Error::from(Error::from(err))),
image::ImageOutputFormat::from(image::ImageFormat::Png), }
)
.ok()?;
Some(image_data.into_inner())
}) })
.await .await
.ok() .map_err(Error::from)?
.flatten()
} }
/// Runs a loop that keeps refreshing the maps when necessary. /// Runs a loop that keeps refreshing the maps when necessary.
@ -575,11 +594,17 @@ pub(crate) async fn run(maps_handle: MapsHandle) {
if maps_handle.needs_pollen_refresh() { if maps_handle.needs_pollen_refresh() {
let retrieved_maps = retrieve_pollen_maps().await; let retrieved_maps = retrieve_pollen_maps().await;
if let Err(e) = retrieved_maps.as_ref() {
eprintln!("💥 Encountered error during pollen maps refresh: {}", e);
}
maps_handle.set_pollen(retrieved_maps); maps_handle.set_pollen(retrieved_maps);
} }
if maps_handle.needs_uvi_refresh() { if maps_handle.needs_uvi_refresh() {
let retrieved_maps = retrieve_uvi_maps().await; let retrieved_maps = retrieve_uvi_maps().await;
if let Err(e) = retrieved_maps.as_ref() {
eprintln!("💥 Encountered error during UVI maps refresh: {}", e);
}
maps_handle.set_uvi(retrieved_maps); maps_handle.set_uvi(retrieved_maps);
} }

View File

@ -2,13 +2,14 @@
//! //!
//! This module contains everything related to geographic coordinate system functionality. //! This module contains everything related to geographic coordinate system functionality.
use std::f64::consts::PI;
use std::hash::Hash; use std::hash::Hash;
use cached::proc_macro::cached; use cached::proc_macro::cached;
use geocoding::{Forward, Openstreetmap, Point}; use geocoding::{Forward, Openstreetmap, Point};
use rocket::tokio; use rocket::tokio;
use std::f64::consts::PI; use crate::{Error, Result};
/// A (geocoded) position. /// A (geocoded) position.
/// ///
@ -98,21 +99,19 @@ impl Eq for Position {}
/// Resolves the geocoded position for a given address. /// Resolves the geocoded position for a given address.
/// ///
/// Returns [`None`] if the address could not be geocoded or the OpenStreetMap Nomatim API could /// If the result is [`Ok`], it will be cached.
/// not be contacted.
///
/// If the result is [`Some`], it will be cached.
/// Note that only the 100 least recently used addresses will be cached. /// Note that only the 100 least recently used addresses will be cached.
#[cached(size = 100)] #[cached(size = 100, result = true)]
pub(crate) async fn resolve_address(address: String) -> Option<Position> { pub(crate) async fn resolve_address(address: String) -> Result<Position> {
println!("🌍 Geocoding the position of the address: {}", address); println!("🌍 Geocoding the position of the address: {}", address);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let osm = Openstreetmap::new(); let osm = Openstreetmap::new();
let points: Vec<Point<f64>> = osm.forward(&address).ok()?; let points: Vec<Point<f64>> = osm.forward(&address)?;
points.get(0).map(Position::from) points
.first()
.ok_or(Error::NoPositionFound)
.map(Position::from)
}) })
.await .await?
.ok()
.flatten()
} }

View File

@ -4,9 +4,8 @@
//! and <https://www.buienradar.nl/overbuienradar/gratis-weerdata>. //! and <https://www.buienradar.nl/overbuienradar/gratis-weerdata>.
use cached::proc_macro::cached; use cached::proc_macro::cached;
use chrono::offset::TimeZone;
use chrono::serde::ts_seconds; use chrono::serde::ts_seconds;
use chrono::{DateTime, Datelike, Duration, NaiveTime, ParseError, Utc}; use chrono::{DateTime, Datelike, Duration, NaiveTime, ParseError, TimeZone, Utc};
use chrono_tz::Europe; use chrono_tz::Europe;
use csv::ReaderBuilder; use csv::ReaderBuilder;
use reqwest::Url; use reqwest::Url;
@ -14,7 +13,7 @@ use rocket::serde::{Deserialize, Serialize};
use crate::maps::MapsHandle; use crate::maps::MapsHandle;
use crate::position::Position; use crate::position::Position;
use crate::Metric; use crate::{Error, Metric, Result};
/// The base URL for the Buienradar API. /// The base URL for the Buienradar API.
const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext"; const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext";
@ -66,10 +65,10 @@ impl TryFrom<Row> for Item {
/// time zone. /// time zone.
fn parse_time(t: &str) -> Result<DateTime<Utc>, ParseError> { fn parse_time(t: &str) -> Result<DateTime<Utc>, ParseError> {
// First, get the current date in the Europe/Amsterdam time zone. // First, get the current date in the Europe/Amsterdam time zone.
let today = Utc::now().with_timezone(&Europe::Amsterdam).date(); let today = Utc::now().with_timezone(&Europe::Amsterdam).date_naive();
// Then, parse the time and interpret it relative to "today". // Then, parse the time and interpret it relative to "today".
let ntime = NaiveTime::parse_from_str(t, "%H:%M")?; let ntime = NaiveTime::parse_from_str(t, "%H:%M")?;
let ndtime = today.naive_local().and_time(ntime); let ndtime = today.and_time(ntime);
// Finally, interpret the naive date/time in the Europe/Amsterdam time zone and convert it to // Finally, interpret the naive date/time in the Europe/Amsterdam time zone and convert it to
// the UTC time zone. // the UTC time zone.
let ldtime = Europe::Amsterdam.from_local_datetime(&ndtime).unwrap(); let ldtime = Europe::Amsterdam.from_local_datetime(&ndtime).unwrap();
@ -98,8 +97,9 @@ fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
let now = Utc::now().with_timezone(&Europe::Amsterdam); let now = Utc::now().with_timezone(&Europe::Amsterdam);
// Use noon on the same day as "now" as a comparison moment. // Use noon on the same day as "now" as a comparison moment.
let noon = Europe::Amsterdam let noon = Europe::Amsterdam
.ymd(now.year(), now.month(), now.day()) .with_ymd_and_hms(now.year(), now.month(), now.day(), 12, 0, 0)
.and_hms(12, 0, 0); .single()
.expect("Invalid date: input date is invalid or not unambiguous");
if now < noon { if now < noon {
// It is still before noon, so bump timestamps after noon a day back. // It is still before noon, so bump timestamps after noon a day back.
@ -107,7 +107,7 @@ fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
.into_iter() .into_iter()
.map(|mut item| { .map(|mut item| {
if item.time > noon { if item.time > noon {
item.time = item.time - Duration::days(1) item.time -= Duration::days(1)
} }
item item
}) })
@ -118,7 +118,7 @@ fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
.into_iter() .into_iter()
.map(|mut item| { .map(|mut item| {
if item.time < noon { if item.time < noon {
item.time = item.time + Duration::days(1) item.time += Duration::days(1)
} }
item item
}) })
@ -128,28 +128,23 @@ fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
/// Retrieves the Buienradar forecasted precipitation items for the provided position. /// Retrieves the Buienradar forecasted precipitation items for the provided position.
/// ///
/// Returns [`None`] if retrieval or deserialization fails. /// If the result is [`Ok`] it will be cached for 5 minutes for the the given position.
/// #[cached(time = 300, result = true)]
/// If the result is [`Some`] it will be cached for 5 minutes for the the given position. async fn get_precipitation(position: Position) -> Result<Vec<Item>> {
#[cached(time = 300, option = true)]
async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap(); let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap();
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("lat", &position.lat_as_str(2)) .append_pair("lat", &position.lat_as_str(2))
.append_pair("lon", &position.lon_as_str(2)); .append_pair("lon", &position.lon_as_str(2));
println!("▶️ Retrieving Buienradar data from: {url}"); println!("▶️ Retrieving Buienradar data from: {url}");
let response = reqwest::get(url).await.ok()?; let response = reqwest::get(url).await?;
let output = match response.error_for_status() { let output = response.error_for_status()?.text().await?;
Ok(res) => res.text().await.ok()?,
Err(_err) => return None,
};
let mut rdr = ReaderBuilder::new() let mut rdr = ReaderBuilder::new()
.has_headers(false) .has_headers(false)
.delimiter(b'|') .delimiter(b'|')
.from_reader(output.as_bytes()); .from_reader(output.as_bytes());
let items: Vec<Item> = rdr.deserialize().collect::<Result<_, _>>().ok()?; let items: Vec<Item> = rdr.deserialize().collect::<Result<_, _>>()?;
// Check if the first item stamp is (timewise) later than the last item stamp. // Check if the first item stamp is (timewise) later than the last item stamp.
// In this case `parse_time` interpreted e.g. 23:00 and later 0:30 in the same day and some // In this case `parse_time` interpreted e.g. 23:00 and later 0:30 in the same day and some
@ -160,46 +155,44 @@ async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
.map(|(it1, it2)| it1.time > it2.time) .map(|(it1, it2)| it1.time > it2.time)
== Some(true) == Some(true)
{ {
Some(fix_items_day_boundary(items)) Ok(fix_items_day_boundary(items))
} else { } else {
Some(items) Ok(items)
} }
} }
/// Retrieves the Buienradar forecasted pollen samples for the provided position. /// Retrieves the Buienradar forecasted pollen samples for the provided position.
/// ///
/// Returns [`None`] if the sampling fails. /// If the result is [`Ok`] if will be cached for 1 hour for the given position.
///
/// If the result is [`Some`] if will be cached for 1 hour for the given position.
#[cached( #[cached(
time = 3_600, time = 3_600,
key = "Position", key = "Position",
convert = r#"{ position }"#, convert = r#"{ position }"#,
option = true result = true
)] )]
async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> { async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Result<Vec<Sample>> {
maps_handle maps_handle
.lock() .lock()
.expect("Maps handle mutex was poisoned") .expect("Maps handle mutex was poisoned")
.pollen_samples(position) .pollen_samples(position)
.map_err(Into::into)
} }
/// Retrieves the Buienradar forecasted UV index samples for the provided position. /// Retrieves the Buienradar forecasted UV index samples for the provided position.
/// ///
/// Returns [`None`] if the sampling fails. /// If the result is [`Ok`] if will be cached for 1 day for the given position.
///
/// If the result is [`Some`] if will be cached for 1 day for the given position.
#[cached( #[cached(
time = 86_400, time = 86_400,
key = "Position", key = "Position",
convert = r#"{ position }"#, convert = r#"{ position }"#,
option = true result = true
)] )]
async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> { async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Result<Vec<Sample>> {
maps_handle maps_handle
.lock() .lock()
.expect("Maps handle mutex was poisoned") .expect("Maps handle mutex was poisoned")
.uvi_samples(position) .uvi_samples(position)
.map_err(Into::into)
} }
/// Retrieves the Buienradar forecasted map samples for the provided position. /// Retrieves the Buienradar forecasted map samples for the provided position.
@ -207,18 +200,15 @@ async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sam
/// It only supports the following metric: /// It only supports the following metric:
/// * [`Metric::Pollen`] /// * [`Metric::Pollen`]
/// * [`Metric::UVI`] /// * [`Metric::UVI`]
///
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
/// this provider.
pub(crate) async fn get_samples( pub(crate) async fn get_samples(
position: Position, position: Position,
metric: Metric, metric: Metric,
maps_handle: &MapsHandle, maps_handle: &MapsHandle,
) -> Option<Vec<Sample>> { ) -> Result<Vec<Sample>> {
match metric { match metric {
Metric::Pollen => get_pollen(position, maps_handle).await, Metric::Pollen => get_pollen(position, maps_handle).await,
Metric::UVI => get_uvi(position, maps_handle).await, Metric::UVI => get_uvi(position, maps_handle).await,
_ => None, _ => Err(Error::UnsupportedMetric(metric)),
} }
} }
@ -227,11 +217,9 @@ pub(crate) async fn get_samples(
/// It only supports the following metric: /// It only supports the following metric:
/// * [`Metric::Precipitation`] /// * [`Metric::Precipitation`]
/// ///
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by pub(crate) async fn get_items(position: Position, metric: Metric) -> Result<Vec<Item>> {
/// this provider.
pub(crate) async fn get_items(position: Position, metric: Metric) -> Option<Vec<Item>> {
match metric { match metric {
Metric::Precipitation => get_precipitation(position).await, Metric::Precipitation => get_precipitation(position).await,
_ => None, _ => Err(Error::UnsupportedMetric(metric)),
} }
} }

View File

@ -11,7 +11,28 @@ pub(crate) use super::buienradar::{self, Sample as BuienradarSample};
pub(crate) use super::luchtmeetnet::{self, Item as LuchtmeetnetItem}; pub(crate) use super::luchtmeetnet::{self, Item as LuchtmeetnetItem};
use crate::maps::MapsHandle; use crate::maps::MapsHandle;
use crate::position::Position; use crate::position::Position;
use crate::Metric; use crate::{Error, Metric};
/// The possible merge errors that can occur.
#[allow(clippy::enum_variant_names)]
#[derive(Debug, thiserror::Error, PartialEq)]
pub(crate) enum MergeError {
/// No AQI item found.
#[error("No AQI item found")]
NoAqiItemFound,
/// No pollen item found.
#[error("No pollen item found")]
NoPollenItemFound,
/// No AQI item found within 30 minutes of first pollen item.
#[error("No AQI item found within 30 minutes of first pollen item")]
NoCloseAqiItemFound,
/// No pollen item found within 30 minutes of first AQI item.
#[error("No pollen item found within 30 minutes of first AQI item")]
NoClosePollenItemFound,
}
/// The combined data item. /// The combined data item.
#[derive(Clone, Debug, PartialEq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
@ -35,18 +56,12 @@ impl Item {
/// Merges pollen samples and AQI items into combined items. /// Merges pollen samples and AQI items into combined items.
/// ///
/// The merging drops items from either the pollen samples or from the AQI items if they are not /// The merging drops items from either the pollen samples or from the AQI items if they are not
/// stamped with half an hour of the first item of the latest starting series, thus lining them /// stamped within half an hour of the first item of the latest starting series, thus lining them
/// before they are combined. /// before they are combined.
///
/// This function also finds the maximum pollen sample and AQI item.
///
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
/// lining them up fails. Returns [`None`] for the maximum pollen sample or maximum AQI item
/// if there are no samples or items.
fn merge( fn merge(
pollen_samples: Vec<BuienradarSample>, pollen_samples: Vec<BuienradarSample>,
aqi_items: Vec<LuchtmeetnetItem>, aqi_items: Vec<LuchtmeetnetItem>,
) -> Option<(Vec<Item>, BuienradarSample, LuchtmeetnetItem)> { ) -> Result<Vec<Item>, MergeError> {
let mut pollen_samples = pollen_samples; let mut pollen_samples = pollen_samples;
let mut aqi_items = aqi_items; let mut aqi_items = aqi_items;
@ -56,52 +71,44 @@ fn merge(
aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600); aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600);
// Align the iterators based on the (hourly) timestamps! // Align the iterators based on the (hourly) timestamps!
let pollen_first_time = pollen_samples.first()?.time; let pollen_first_time = pollen_samples
let aqi_first_time = aqi_items.first()?.time; .first()
.ok_or(MergeError::NoPollenItemFound)?
.time;
let aqi_first_time = aqi_items.first().ok_or(MergeError::NoAqiItemFound)?.time;
if pollen_first_time < aqi_first_time { if pollen_first_time < aqi_first_time {
// Drain one or more pollen samples to line up. // Drain one or more pollen samples to line up.
let idx = pollen_samples.iter().position(|smp| { let idx = pollen_samples
smp.time .iter()
.signed_duration_since(aqi_first_time) .position(|smp| {
.num_seconds() smp.time
.abs() .signed_duration_since(aqi_first_time)
< 1800 .num_seconds()
})?; .abs()
< 1800
})
.ok_or(MergeError::NoCloseAqiItemFound)?;
pollen_samples.drain(..idx); pollen_samples.drain(..idx);
} else { } else {
// Drain one or more AQI items to line up. // Drain one or more AQI items to line up.
let idx = aqi_items.iter().position(|item| { let idx = aqi_items
item.time .iter()
.signed_duration_since(pollen_first_time) .position(|item| {
.num_seconds() item.time
.abs() .signed_duration_since(pollen_first_time)
< 1800 .num_seconds()
})?; .abs()
< 1800
})
.ok_or(MergeError::NoClosePollenItemFound)?;
aqi_items.drain(..idx); aqi_items.drain(..idx);
} }
// Find the maximum sample/item of each series.
// Note 1: Unwrapping is possible because each series has at least an item otherwise `.first`
// would have failed above.
// Note 2: Ensure that the maximum sample/item is in scope of the time range covered by the
// combined items.
let zip_len = std::cmp::min(pollen_samples.len(), aqi_items.len());
let pollen_max = pollen_samples[..zip_len]
.iter()
.max_by_key(|sample| sample.score)
.cloned()
.unwrap();
let aqi_max = aqi_items[..zip_len]
.iter()
.max_by_key(|item| (item.value * 1_000.0) as u32)
.cloned()
.unwrap();
// Combine the samples with items by taking the maximum of pollen sample score and AQI item // Combine the samples with items by taking the maximum of pollen sample score and AQI item
// value. // value.
let items = pollen_samples let items = pollen_samples
.into_iter() .into_iter()
.zip(aqi_items.into_iter()) .zip(aqi_items)
.map(|(pollen_sample, aqi_item)| { .map(|(pollen_sample, aqi_item)| {
let time = pollen_sample.time; let time = pollen_sample.time;
let value = (pollen_sample.score as f32).max(aqi_item.value); let value = (pollen_sample.score as f32).max(aqi_item.value);
@ -110,42 +117,32 @@ fn merge(
}) })
.collect(); .collect();
Some((items, pollen_max, aqi_max)) Ok(items)
} }
/// Retrieves the combined forecasted items for the provided position and metric. /// Retrieves the combined forecasted items for the provided position and metric.
/// ///
/// Besides the combined items, it also yields the maxium pollen sample and AQI item.
/// Note that the maximum values are calculated before combining them, so the time stamp
/// corresponds to the one in the original series, not to a timestamp of an item after merging.
///
/// It supports the following metric: /// It supports the following metric:
/// * [`Metric::PAQI`] /// * [`Metric::PAQI`]
///
/// Returns [`None`] for the combined items if retrieving data from either the Buienradar or the
/// Luchtmeetnet provider fails or if they cannot be combined. Returns [`None`] for the maxiumum
/// pollen sample or AQI item if there are no samples or items.
///
/// If the result is [`Some`], it will be cached for 30 minutes for the the given position and
/// metric.
#[cached( #[cached(
time = 1800, time = 1800,
key = "(Position, Metric)", key = "(Position, Metric)",
convert = r#"{ (position, metric) }"#, convert = r#"{ (position, metric) }"#,
option = true result = true
)] )]
pub(crate) async fn get( pub(crate) async fn get(
position: Position, position: Position,
metric: Metric, metric: Metric,
maps_handle: &MapsHandle, maps_handle: &MapsHandle,
) -> Option<(Vec<Item>, BuienradarSample, LuchtmeetnetItem)> { ) -> Result<Vec<Item>, Error> {
if metric != Metric::PAQI { if metric != Metric::PAQI {
return None; return Err(Error::UnsupportedMetric(metric));
}; };
let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await; let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await?;
let aqi_items = luchtmeetnet::get(position, Metric::AQI).await; let aqi_items = luchtmeetnet::get(position, Metric::AQI).await?;
let items = merge(pollen_items, aqi_items)?;
merge(pollen_items?, aqi_items?) Ok(items)
} }
#[cfg(test)] #[cfg(test)]
@ -184,8 +181,8 @@ mod tests {
// Perform a normal merge. // Perform a normal merge.
let merged = super::merge(pollen_samples.clone(), aqi_items.clone()); let merged = super::merge(pollen_samples.clone(), aqi_items.clone());
assert!(merged.is_some()); assert!(merged.is_ok());
let (paqi, max_pollen, max_aqi) = merged.unwrap(); let paqi = merged.unwrap();
assert_eq!( assert_eq!(
paqi, paqi,
Vec::from([ Vec::from([
@ -194,8 +191,6 @@ mod tests {
Item::new(t_2, 2.4), Item::new(t_2, 2.4),
]) ])
); );
assert_eq!(max_pollen, BuienradarSample::new(t_1, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_1, 2.9));
// The pollen samples are shifted, i.e. one hour in the future. // The pollen samples are shifted, i.e. one hour in the future.
let shifted_pollen_samples = pollen_samples[2..] let shifted_pollen_samples = pollen_samples[2..]
@ -207,11 +202,9 @@ mod tests {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let merged = super::merge(shifted_pollen_samples, aqi_items.clone()); let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
assert!(merged.is_some()); assert!(merged.is_ok());
let (paqi, max_pollen, max_aqi) = merged.unwrap(); let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)])); assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)]));
assert_eq!(max_pollen, BuienradarSample::new(t_2, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_1, 2.9));
// The AQI items are shifted, i.e. one hour in the future. // The AQI items are shifted, i.e. one hour in the future.
let shifted_aqi_items = aqi_items[2..] let shifted_aqi_items = aqi_items[2..]
@ -223,26 +216,20 @@ mod tests {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items); let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
assert!(merged.is_some()); assert!(merged.is_ok());
let (paqi, max_pollen, max_aqi) = merged.unwrap(); let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)])); assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)]));
assert_eq!(max_pollen, BuienradarSample::new(t_1, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_2, 2.9));
// The maximum sample/item should not be later then the interval the PAQI items cover. // The maximum sample/item should not be later then the interval the PAQI items cover.
let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone()); let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone());
assert!(merged.is_some()); assert!(merged.is_ok());
let (paqi, max_pollen, max_aqi) = merged.unwrap(); let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)])); assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
assert_eq!(max_pollen, BuienradarSample::new(t_0, 1));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_0, 1.1));
let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec()); let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec());
assert!(merged.is_some()); assert!(merged.is_ok());
let (paqi, max_pollen, max_aqi) = merged.unwrap(); let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)])); assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
assert_eq!(max_pollen, BuienradarSample::new(t_0, 1));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_0, 1.1));
// Merging fails because the samples/items are too far (6 hours) apart. // Merging fails because the samples/items are too far (6 hours) apart.
let shifted_aqi_items = aqi_items let shifted_aqi_items = aqi_items
@ -254,18 +241,29 @@ mod tests {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items); let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
assert_eq!(merged, None); assert_eq!(merged, Err(MergeError::NoCloseAqiItemFound));
let shifted_pollen_samples = pollen_samples
.iter()
.cloned()
.map(|mut item| {
item.time = item.time.checked_add_signed(Duration::hours(6)).unwrap();
item
})
.collect::<Vec<_>>();
let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
assert_eq!(merged, Err(MergeError::NoClosePollenItemFound));
// The pollen samples list is empty, or everything is too old. // The pollen samples list is empty, or everything is too old.
let merged = super::merge(Vec::new(), aqi_items.clone()); let merged = super::merge(Vec::new(), aqi_items.clone());
assert_eq!(merged, None); assert_eq!(merged, Err(MergeError::NoPollenItemFound));
let merged = super::merge(pollen_samples[0..2].to_vec(), aqi_items.clone()); let merged = super::merge(pollen_samples[0..2].to_vec(), aqi_items.clone());
assert_eq!(merged, None); assert_eq!(merged, Err(MergeError::NoPollenItemFound));
// The AQI items list is empty, or everything is too old. // The AQI items list is empty, or everything is too old.
let merged = super::merge(pollen_samples.clone(), Vec::new()); let merged = super::merge(pollen_samples.clone(), Vec::new());
assert_eq!(merged, None); assert_eq!(merged, Err(MergeError::NoAqiItemFound));
let merged = super::merge(pollen_samples, aqi_items[0..2].to_vec()); let merged = super::merge(pollen_samples, aqi_items[0..2].to_vec());
assert_eq!(merged, None); assert_eq!(merged, Err(MergeError::NoAqiItemFound));
} }
} }

View File

@ -9,7 +9,7 @@ use reqwest::Url;
use rocket::serde::{Deserialize, Serialize}; use rocket::serde::{Deserialize, Serialize};
use crate::position::Position; use crate::position::Position;
use crate::Metric; use crate::{Error, Metric, Result};
/// The base URL for the Luchtmeetnet API. /// The base URL for the Luchtmeetnet API.
const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations"; const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations";
@ -54,20 +54,14 @@ impl Item {
/// * [`Metric::NO2`] /// * [`Metric::NO2`]
/// * [`Metric::O3`] /// * [`Metric::O3`]
/// * [`Metric::PM10`] /// * [`Metric::PM10`]
/// #[cached(time = 1800, result = true)]
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by pub(crate) async fn get(position: Position, metric: Metric) -> Result<Vec<Item>> {
/// this provider.
///
/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and
/// metric.
#[cached(time = 1800, option = true)]
pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>> {
let formula = match metric { let formula = match metric {
Metric::AQI => "lki", Metric::AQI => "lki",
Metric::NO2 => "no2", Metric::NO2 => "no2",
Metric::O3 => "o3", Metric::O3 => "o3",
Metric::PM10 => "pm10", Metric::PM10 => "pm10",
_ => return None, // Unsupported metric _ => return Err(Error::UnsupportedMetric(metric)),
}; };
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap(); let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
url.query_pairs_mut() url.query_pairs_mut()
@ -76,11 +70,8 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>>
.append_pair("longitude", &position.lon_as_str(5)); .append_pair("longitude", &position.lon_as_str(5));
println!("▶️ Retrieving Luchtmeetnet data from: {url}"); println!("▶️ Retrieving Luchtmeetnet data from: {url}");
let response = reqwest::get(url).await.ok()?; let response = reqwest::get(url).await?;
let root: Container = match response.error_for_status() { let root: Container = response.error_for_status()?.json().await?;
Ok(res) => res.json().await.ok()?,
Err(_err) => return None,
};
// Filter items that are older than one hour before now. They seem to occur sometimes? // Filter items that are older than one hour before now. They seem to occur sometimes?
let too_old = Utc::now() - Duration::hours(1); let too_old = Utc::now() - Duration::hours(1);
@ -90,5 +81,5 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>>
.filter(|item| item.time > too_old) .filter(|item| item.time > too_old)
.collect(); .collect();
Some(items) Ok(items)
} }