Compare commits
108 commits
Author | SHA1 | Date | |
---|---|---|---|
56f2e79446 | |||
c197051859 | |||
f156bdb4dd | |||
70b59022e7 | |||
1146e444b3 | |||
c5bf143c55 | |||
27be36630f | |||
964b2f5603 | |||
7492d3b524 | |||
690e236a65 | |||
01ee1f701d | |||
ba1b27fd66 | |||
94c29cad71 | |||
8e5a1ef305 | |||
6c39cac26e | |||
f1ee03d96c | |||
98f60cba89 | |||
45ee951601 | |||
f4e7c82b53 | |||
a29d7f3535 | |||
c86f001fee | |||
88c59cdb1f | |||
cad766b520 | |||
e62699c102 | |||
f32f67dbf4 | |||
d1e43a7aa7 | |||
c2450267e0 | |||
087ecf00f1 | |||
f8ea25c516 | |||
f830d34464 | |||
ff10cc19e8 | |||
1211fea46a | |||
182521aab7 | |||
dadf5d3147 | |||
4b506541f3 | |||
47e28a7098 | |||
07e0701106 | |||
91d5500c86 | |||
9b3c11ee76 | |||
27e1ac726c | |||
3047cf74c2 | |||
44474aa545 | |||
50b0e94839 | |||
1010311403 | |||
d16699636b | |||
38fb28c248 | |||
7c2b012e95 | |||
ab6001f072 | |||
9742331f6d | |||
9bb9d248a8 | |||
37788fac1c | |||
112875e7ac | |||
1c71ca79ef | |||
afca20c96f | |||
2d34eee49a | |||
a52313ffb7 | |||
f39a3a33ee | |||
a59b4eefe1 | |||
1aad3e2eb6 | |||
929508a9cc | |||
23e4f731a0 | |||
d84440304a | |||
a289bd9ef0 | |||
122f98a92d | |||
1426405943 | |||
34be96d187 | |||
bc140a9d1e | |||
39c224eb90 | |||
b517448fd7 | |||
3de66dbd41 | |||
6a04fc958f | |||
c8b951ab7e | |||
32ec6b516c | |||
a6301fa678 | |||
f00537d5f3 | |||
c8970fa3bb | |||
aee3409f4a | |||
dbdd7bef0f | |||
d749233b24 | |||
abb6657212 | |||
8b03f2162b | |||
789bb1d1ac | |||
2e999f5a78 | |||
712b3a9acf | |||
2b23885692 | |||
dc47c1c73c | |||
014ca5a151 | |||
8a2a6d769d | |||
69ef08002c | |||
7d0cd4a822 | |||
fb8236696d | |||
aab3b737be | |||
3451445de1 | |||
b88c7f77a0 | |||
705ffae249 | |||
bd2344beb6 | |||
bad20b803a | |||
fc4672328f | |||
769f0745c8 | |||
3fb899d1fd | |||
f0cc54f074 | |||
ac653ef0c9 | |||
e204e7905c | |||
08cdfe1e1c | |||
89395f21f6 | |||
ff9f1ac371 | |||
4a6eeab787 | |||
ab4b0bba72 |
16 changed files with 2301 additions and 1899 deletions
26
.forgejo/workflows/audit.yml
Normal file
26
.forgejo/workflows/audit.yml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
name: "Audit dependencies"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '.forgejo/workflows/audit.yml'
|
||||||
|
- '**/Cargo.toml'
|
||||||
|
- '**/Cargo.lock'
|
||||||
|
- '**/audit.toml'
|
||||||
|
schedule:
|
||||||
|
- cron: '0 23 * * 6'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: rust-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Run cargo audit
|
||||||
|
run: cargo audit
|
28
.forgejo/workflows/check-lint-test.yml
Normal file
28
.forgejo/workflows/check-lint-test.yml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
name: "Check, lint and test"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check_lint:
|
||||||
|
name: Check, lint and test
|
||||||
|
runs-on: rust-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Run cargo check
|
||||||
|
run: cargo check --all-features
|
||||||
|
|
||||||
|
- name: Run cargo clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Run cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Run cargo test
|
||||||
|
run: cargo test --all-features
|
91
.forgejo/workflows/release.yml
Normal file
91
.forgejo/workflows/release.yml
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
name: "Release"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: "Release"
|
||||||
|
runs-on: rust-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Determine the repository name and version of the release
|
||||||
|
run: |
|
||||||
|
REPO_NAME=$(basename $GITHUB_REPOSITORY)
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Releasing version of $REPO_NAME: $VERSION"
|
||||||
|
echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
|
||||||
|
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: Build a release binary
|
||||||
|
run: |
|
||||||
|
# FIXME: This should be figured out in a better manner!
|
||||||
|
BIN_NAME=${REPO_NAME}-x86_64-unknown-linux-gnu
|
||||||
|
cargo build --release
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/${REPO_NAME} dist/${BIN_NAME}
|
||||||
|
shasum -a 256 dist/${BIN_NAME} > dist/${BIN_NAME}.sha256sum
|
||||||
|
|
||||||
|
- name: Release to Forgejo
|
||||||
|
uses: paul/forgejo-release@main
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
release-dir: dist
|
||||||
|
release-notes: '${{ env.RELEASE_NOTES }}'
|
||||||
|
title: 'Release ${{ env.VERSION }}'
|
||||||
|
token: '${{ secrets.RELEASE_TOKEN }}'
|
||||||
|
|
||||||
|
release-crate:
|
||||||
|
name: "Release Rust crate"
|
||||||
|
runs-on: rust-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Run cargo publish
|
||||||
|
run: cargo publish --registry luon
|
||||||
|
env:
|
||||||
|
CARGO_REGISTRIES_LUON_INDEX: 'sparse+${{ github.server_url }}/api/packages/${{ github.repository_owner }}/cargo/'
|
||||||
|
CARGO_REGISTRIES_LUON_TOKEN: 'Bearer ${{ secrets.CARGO_TOKEN }}'
|
||||||
|
|
||||||
|
release-deb:
|
||||||
|
name: "Release Debian package"
|
||||||
|
runs-on: rust-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install and run cargo-deb
|
||||||
|
run: |
|
||||||
|
unset GITHUB_TOKEN
|
||||||
|
cargo binstall --only-signed -y cargo-deb
|
||||||
|
cargo 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/*.deb \
|
||||||
|
${{ github.server_url }}/api/packages/${{ github.repository_owner }}/debian/pool/bookworm/main/upload
|
190
CHANGELOG.md
190
CHANGELOG.md
|
@ -7,7 +7,181 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.2.1] - 2002-05-08
|
## [0.2.13] - 2024-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* Switch to Forgejo Actions; add audit workflow
|
||||||
|
* Update dependency on the `cached` crate
|
||||||
|
* Switch build dependency on `vergen` to `vergen-git2`
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
* Update dependencies; this fixes security advisory
|
||||||
|
[RUSTSEC-2024-0357](https://rustsec.org/advisories/RUSTSEC-2024-0357)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* Switch to Rocket 0.5 RC2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Fix timestamps for map samples not being correct (AQI, PAQI, UVI metrics) (#22)
|
||||||
|
* Valid samples/items will no longer be discarded too early
|
||||||
|
|
||||||
|
## [0.2.1] - 2022-05-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
@ -28,7 +202,19 @@ 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.1...HEAD
|
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.13...HEAD
|
||||||
|
[0.2.13]: https://git.luon.net/paul/sinoptik/compare/v0.2.12...v0.2.13
|
||||||
|
[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.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
|
||||||
[0.1.0]: https://git.luon.net/paul/sinoptik/commits/tag/v0.1.0
|
[0.1.0]: https://git.luon.net/paul/sinoptik/commits/tag/v0.1.0
|
||||||
|
|
2523
Cargo.lock
generated
2523
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
22
Cargo.toml
22
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "sinoptik"
|
name = "sinoptik"
|
||||||
version = "0.2.1"
|
version = "0.2.13"
|
||||||
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,20 +12,23 @@ repository = "https://git.luon.net/paul/sinoptik"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cached = { version = "0.34.0", features = ["async"] }
|
cached = { version = "0.53.1", 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.11.27", features = ["json"] }
|
||||||
rocket = { version = "0.5.0-rc.1", features = ["json"] }
|
rocket = { version = "0.5.0-rc.3", features = ["json"] }
|
||||||
|
thiserror = "1.0.31"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_float_eq = "1.1.3"
|
assert_float_eq = "1.1.3"
|
||||||
assert_matches = "1.5.0"
|
assert_matches = "1.5.0"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
vergen-git2 = { version = "1.0.0", features = ["build"] }
|
||||||
|
|
||||||
[package.metadata.deb]
|
[package.metadata.deb]
|
||||||
maintainer = "Paul van Tilburg <paul@luon.net>"
|
maintainer = "Paul van Tilburg <paul@luon.net>"
|
||||||
copyright = "2022, Paul van Tilburg"
|
copyright = "2022, Paul van Tilburg"
|
||||||
|
@ -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
120
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
15
build.rs
Normal file
15
build.rs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
use std::error::Error;
|
||||||
|
use vergen_git2::{BuildBuilder, Emitter, Git2Builder};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
// Generate the `cargo:` instructions to fill the appropriate environment variables.
|
||||||
|
let build = &BuildBuilder::all_build()?;
|
||||||
|
let git2 = &Git2Builder::all_git()?;
|
||||||
|
|
||||||
|
Emitter::default()
|
||||||
|
.add_instructions(build)?
|
||||||
|
.add_instructions(git2)?
|
||||||
|
.emit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
101
src/forecast.rs
101
src/forecast.rs
|
@ -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
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Metric::NO2 => {
|
||||||
|
forecast.no2 = providers::luchtmeetnet::get(position, metric)
|
||||||
|
.await
|
||||||
|
.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 => {
|
Metric::PAQI => {
|
||||||
if let Some((paqi, pollen_max, aqi_max)) =
|
forecast.paqi = providers::combined::get(position, metric, maps_handle)
|
||||||
providers::combined::get(position, metric, maps_handle).await
|
.await
|
||||||
{
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
forecast.paqi = Some(paqi);
|
.ok()
|
||||||
forecast.aqi_max = Some(aqi_max);
|
|
||||||
forecast.pollen_max = Some(pollen_max);
|
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
221
src/lib.rs
221
src/lib.rs
|
@ -1,23 +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::http::ContentType;
|
use rocket::fairing::AdHoc;
|
||||||
use rocket::response::content::Custom;
|
use rocket::http::Status;
|
||||||
|
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;
|
||||||
|
@ -25,17 +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)]
|
||||||
|
#[response(content_type = "image/png")]
|
||||||
|
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.
|
||||||
|
@ -61,11 +156,11 @@ async fn map_address(
|
||||||
address: String,
|
address: String,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<Custom<Vec<u8>>> {
|
) -> 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;
|
||||||
|
|
||||||
image_data.map(|id| Custom(ContentType::PNG, id))
|
image_data.map(PngImageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||||
|
@ -77,29 +172,64 @@ async fn map_geo(
|
||||||
lon: f64,
|
lon: f64,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<Custom<Vec<u8>>> {
|
) -> 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(|id| Custom(ContentType::PNG, id))
|
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)]
|
||||||
|
@ -107,16 +237,19 @@ 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;
|
||||||
|
|
||||||
|
use super::maps::RetrievedMaps;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn maps_stub(map_count: u32) -> DynamicImage {
|
fn maps_stub(map_count: u32) -> RetrievedMaps {
|
||||||
let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key.
|
let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key.
|
||||||
|
let image =
|
||||||
|
DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color));
|
||||||
|
|
||||||
DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color))
|
RetrievedMaps::new(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maps_handle_stub() -> MapsHandle {
|
fn maps_handle_stub() -> MapsHandle {
|
||||||
|
@ -136,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);
|
||||||
|
|
||||||
|
@ -156,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(_)));
|
||||||
}
|
}
|
||||||
|
@ -184,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);
|
||||||
|
|
||||||
|
@ -204,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(_)));
|
||||||
}
|
}
|
||||||
|
@ -219,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
|
||||||
|
@ -247,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
|
||||||
|
@ -276,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
36
src/main.rs
36
src/main.rs
|
@ -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?
|
|
||||||
}
|
|
||||||
result = maps_refresher => {
|
|
||||||
shutdown.notify();
|
|
||||||
result?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
425
src/maps.rs
425
src/maps.rs
|
@ -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, 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,26 +162,20 @@ 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<(DynamicImage, DateTime<Utc>)>);
|
fn set_pollen(&self, result: Result<RetrievedMaps>);
|
||||||
|
|
||||||
/// Updates the UV index maps.
|
/// Updates the UV index maps.
|
||||||
fn set_uvi(&self, result: Option<(DynamicImage, DateTime<Utc>)>);
|
fn set_uvi(&self, result: Result<RetrievedMaps>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Container type for all in-memory cached maps.
|
/// Container type for all in-memory cached maps.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct Maps {
|
pub(crate) struct Maps {
|
||||||
/// The pollen maps (from Buienradar).
|
/// The pollen maps (from Buienradar).
|
||||||
pub(crate) pollen: Option<DynamicImage>,
|
pub(crate) pollen: Option<RetrievedMaps>,
|
||||||
|
|
||||||
/// The timestamp the pollen maps were last refreshed.
|
|
||||||
pollen_stamp: DateTime<Utc>,
|
|
||||||
|
|
||||||
/// The UV index maps (from Buienradar).
|
/// The UV index maps (from Buienradar).
|
||||||
pub(crate) uvi: Option<DynamicImage>,
|
pub(crate) uvi: Option<RetrievedMaps>,
|
||||||
|
|
||||||
/// The timestamp the UV index maps were last refreshed.
|
|
||||||
uvi_stamp: DateTime<Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Maps {
|
impl Maps {
|
||||||
|
@ -141,91 +184,60 @@ impl Maps {
|
||||||
/// It contains an [`DynamicImage`] per maps type, if downloaded, and the timestamp of the last
|
/// It contains an [`DynamicImage`] per maps type, if downloaded, and the timestamp of the last
|
||||||
/// update.
|
/// update.
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
let now = Utc::now();
|
|
||||||
Self {
|
Self {
|
||||||
pollen: None,
|
pollen: None,
|
||||||
pollen_stamp: now,
|
|
||||||
uvi: None,
|
uvi: None,
|
||||||
uvi_stamp: now,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 map = map_at(
|
image,
|
||||||
maps,
|
stamp,
|
||||||
self.pollen_stamp,
|
|
||||||
POLLEN_MAP_INTERVAL,
|
POLLEN_MAP_INTERVAL,
|
||||||
POLLEN_MAP_COUNT,
|
POLLEN_MAP_COUNT,
|
||||||
Utc::now(),
|
Utc::now(),
|
||||||
)?;
|
)?;
|
||||||
let coords = project(&map, POLLEN_MAP_REF_POINTS, position)?;
|
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
|
|
||||||
Some(mark(map, 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>> {
|
|
||||||
self.pollen.as_ref().and_then(|maps| {
|
|
||||||
let map = maps.view(0, 0, maps.width() / UVI_MAP_COUNT, maps.height());
|
|
||||||
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
|
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
|
let stamp = maps.timestamp_base;
|
||||||
|
|
||||||
sample(
|
sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords)
|
||||||
maps,
|
|
||||||
self.pollen_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 map = map_at(
|
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
maps,
|
|
||||||
self.uvi_stamp,
|
|
||||||
UVI_MAP_INTERVAL,
|
|
||||||
UVI_MAP_COUNT,
|
|
||||||
Utc::now(),
|
|
||||||
)?;
|
|
||||||
let coords = project(&map, POLLEN_MAP_REF_POINTS, position)?;
|
|
||||||
|
|
||||||
Some(mark(map, 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>> {
|
|
||||||
self.uvi.as_ref().and_then(|maps| {
|
|
||||||
let map = maps.view(0, 0, maps.width() / UVI_MAP_COUNT, maps.height());
|
|
||||||
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
|
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
|
||||||
|
let stamp = maps.timestamp_base;
|
||||||
|
|
||||||
sample(
|
sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords)
|
||||||
maps,
|
|
||||||
self.uvi_stamp,
|
|
||||||
UVI_MAP_INTERVAL,
|
|
||||||
UVI_MAP_COUNT,
|
|
||||||
coords,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,60 +245,66 @@ impl MapsRefresh for MapsHandle {
|
||||||
fn is_pollen_stale(&self) -> bool {
|
fn is_pollen_stale(&self) -> bool {
|
||||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||||
|
|
||||||
Utc::now().signed_duration_since(maps.pollen_stamp)
|
match &maps.pollen {
|
||||||
|
Some(pollen_maps) => {
|
||||||
|
Utc::now().signed_duration_since(pollen_maps.mtime)
|
||||||
> Duration::seconds(POLLEN_MAP_COUNT as i64 * POLLEN_MAP_INTERVAL)
|
> Duration::seconds(POLLEN_MAP_COUNT as i64 * POLLEN_MAP_INTERVAL)
|
||||||
}
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_uvi_stale(&self) -> bool {
|
fn is_uvi_stale(&self) -> bool {
|
||||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||||
|
|
||||||
Utc::now().signed_duration_since(maps.uvi_stamp)
|
match &maps.uvi {
|
||||||
|
Some(uvi_maps) => {
|
||||||
|
Utc::now().signed_duration_since(uvi_maps.mtime)
|
||||||
> Duration::seconds(UVI_MAP_COUNT as i64 * UVI_MAP_INTERVAL)
|
> Duration::seconds(UVI_MAP_COUNT as i64 * UVI_MAP_INTERVAL)
|
||||||
}
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn needs_pollen_refresh(&self) -> bool {
|
fn needs_pollen_refresh(&self) -> bool {
|
||||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||||
|
|
||||||
maps.pollen.is_none()
|
match &maps.pollen {
|
||||||
|| Utc::now()
|
Some(pollen_maps) => {
|
||||||
.signed_duration_since(maps.pollen_stamp)
|
Utc::now()
|
||||||
|
.signed_duration_since(pollen_maps.mtime)
|
||||||
.num_seconds()
|
.num_seconds()
|
||||||
> POLLEN_INTERVAL
|
> POLLEN_INTERVAL
|
||||||
}
|
}
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn needs_uvi_refresh(&self) -> bool {
|
fn needs_uvi_refresh(&self) -> bool {
|
||||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||||
|
|
||||||
maps.uvi.is_none()
|
match &maps.uvi {
|
||||||
|| Utc::now()
|
Some(uvi_maps) => {
|
||||||
.signed_duration_since(maps.uvi_stamp)
|
Utc::now()
|
||||||
|
.signed_duration_since(uvi_maps.mtime)
|
||||||
.num_seconds()
|
.num_seconds()
|
||||||
> UVI_INTERVAL
|
> UVI_INTERVAL
|
||||||
}
|
}
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_pollen(&self, result: Option<(DynamicImage, DateTime<Utc>)>) {
|
fn set_pollen(&self, retrieved_maps: Result<RetrievedMaps>) {
|
||||||
if result.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.ok();
|
||||||
if let Some((pollen, pollen_stamp)) = result {
|
|
||||||
maps.pollen = Some(pollen);
|
|
||||||
maps.pollen_stamp = pollen_stamp
|
|
||||||
} else {
|
|
||||||
maps.pollen = None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_uvi(&self, result: Option<(DynamicImage, DateTime<Utc>)>) {
|
fn set_uvi(&self, retrieved_maps: Result<RetrievedMaps>) {
|
||||||
if result.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.ok();
|
||||||
if let Some((uvi, uvi_stamp)) = result {
|
|
||||||
maps.uvi = Some(uvi);
|
|
||||||
maps.uvi_stamp = uvi_stamp
|
|
||||||
} else {
|
|
||||||
maps.uvi = None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -328,26 +346,27 @@ 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>>>(
|
||||||
maps: &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 = maps.width() / count;
|
let width = image.width() / count;
|
||||||
let height = maps.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);
|
||||||
let mut time = stamp;
|
let mut time = stamp;
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
|
||||||
while offset < maps.width() {
|
while offset < image.width() {
|
||||||
let map = maps.view(
|
let map = image.view(
|
||||||
x.saturating_sub(MAP_SAMPLE_SIZE[0] / 2) + offset,
|
x.saturating_sub(MAP_SAMPLE_SIZE[0] / 2) + offset,
|
||||||
y.saturating_sub(MAP_SAMPLE_SIZE[1] / 2),
|
y.saturating_sub(MAP_SAMPLE_SIZE[1] / 2),
|
||||||
max_sample_width,
|
max_sample_width,
|
||||||
|
@ -364,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
|
||||||
|
@ -374,112 +393,144 @@ 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.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct RetrievedMaps {
|
||||||
|
/// The image data.
|
||||||
|
pub(crate) image: DynamicImage,
|
||||||
|
|
||||||
|
/// The date/time the image was last modified.
|
||||||
|
pub(crate) mtime: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// The starting date/time the image corresponds with.
|
||||||
|
pub(crate) timestamp_base: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetrievedMaps {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new(image: DynamicImage) -> Self {
|
||||||
|
let mtime = Utc::now();
|
||||||
|
let timestamp_base = Utc::now();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
image,
|
||||||
|
mtime,
|
||||||
|
timestamp_base,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<(DynamicImage, DateTime<Utc>)> {
|
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)?
|
let timestamp_base = {
|
||||||
.map(DateTime::<Utc>::from)
|
let path = response.url().path();
|
||||||
.ok()?;
|
let (_, filename) = path
|
||||||
let bytes = response.bytes().await.ok()?;
|
.rsplit_once('/')
|
||||||
|
.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")?;
|
||||||
|
|
||||||
|
Utc.from_utc_datetime(×tamp)
|
||||||
|
};
|
||||||
|
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((image, mtime))
|
.map(|image| RetrievedMaps {
|
||||||
} else {
|
image,
|
||||||
None
|
mtime,
|
||||||
}
|
timestamp_base,
|
||||||
})
|
})
|
||||||
.await
|
.map_err(Error::from)
|
||||||
.ok()?
|
})
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<(DynamicImage, DateTime<Utc>)> {
|
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", ×tamp);
|
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
||||||
|
|
||||||
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<(DynamicImage, DateTime<Utc>)> {
|
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", ×tamp);
|
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
||||||
|
|
||||||
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(
|
||||||
maps: &DynamicImage,
|
image: &DynamicImage,
|
||||||
maps_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(maps_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 = maps.width() / count;
|
let width = image.width() / count;
|
||||||
|
|
||||||
Some(maps.crop_imm(offset * width, 0, width, maps.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.
|
||||||
fn mark(mut map: DynamicImage, coords: (u32, u32)) -> DynamicImage {
|
fn mark(mut image: DynamicImage, coords: (u32, u32)) -> DynamicImage {
|
||||||
let (x, y) = coords;
|
let (x, y) = coords;
|
||||||
|
|
||||||
for py in 0..map.height() {
|
for py in 0..image.height() {
|
||||||
map.put_pixel(x, py, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
image.put_pixel(x, py, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
||||||
}
|
}
|
||||||
for px in 0..map.width() {
|
for px in 0..image.width() {
|
||||||
map.put_pixel(px, y, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
image.put_pixel(px, y, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
||||||
}
|
}
|
||||||
|
|
||||||
map
|
image
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Projects the provided geocoded position to a coordinate on a map.
|
/// Projects the provided geocoded position to a coordinate on a map.
|
||||||
///
|
///
|
||||||
/// 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>(
|
||||||
map: &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];
|
||||||
|
@ -495,10 +546,10 @@ fn project<I: GenericImageView>(
|
||||||
let scale_y = ((ref1_y - ref2_y) as f64) / (ref2_merc_y - ref1_merc_y);
|
let scale_y = ((ref1_y - ref2_y) as f64) / (ref2_merc_y - ref1_merc_y);
|
||||||
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 map.in_bounds(x, y) {
|
if image.in_bounds(x, y) {
|
||||||
Some((x, y))
|
Ok((x, y))
|
||||||
} else {
|
} else {
|
||||||
None
|
Err(Error::OutOfBoundCoords(x, y))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -509,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.
|
||||||
|
@ -548,13 +593,19 @@ pub(crate) async fn run(maps_handle: MapsHandle) {
|
||||||
println!("🕔 Refreshing the maps (if necessary)...");
|
println!("🕔 Refreshing the maps (if necessary)...");
|
||||||
|
|
||||||
if maps_handle.needs_pollen_refresh() {
|
if maps_handle.needs_pollen_refresh() {
|
||||||
let result = retrieve_pollen_maps().await;
|
let retrieved_maps = retrieve_pollen_maps().await;
|
||||||
maps_handle.set_pollen(result);
|
if let Err(e) = retrieved_maps.as_ref() {
|
||||||
|
eprintln!("💥 Encountered error during pollen maps refresh: {}", e);
|
||||||
|
}
|
||||||
|
maps_handle.set_pollen(retrieved_maps);
|
||||||
}
|
}
|
||||||
|
|
||||||
if maps_handle.needs_uvi_refresh() {
|
if maps_handle.needs_uvi_refresh() {
|
||||||
let result = retrieve_uvi_maps().await;
|
let retrieved_maps = retrieve_uvi_maps().await;
|
||||||
maps_handle.set_uvi(result);
|
if let Err(e) = retrieved_maps.as_ref() {
|
||||||
|
eprintln!("💥 Encountered error during UVI maps refresh: {}", e);
|
||||||
|
}
|
||||||
|
maps_handle.set_uvi(retrieved_maps);
|
||||||
}
|
}
|
||||||
|
|
||||||
sleep(REFRESH_INTERVAL).await;
|
sleep(REFRESH_INTERVAL).await;
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,17 +4,16 @@
|
||||||
//! 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, Tz};
|
||||||
use csv::ReaderBuilder;
|
use csv::ReaderBuilder;
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use rocket::serde::{Deserialize, Serialize};
|
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";
|
||||||
|
@ -49,6 +48,13 @@ pub(crate) struct Item {
|
||||||
pub(crate) value: f32,
|
pub(crate) value: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new(time: DateTime<Utc>, value: f32) -> Self {
|
||||||
|
Self { time, value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TryFrom<Row> for Item {
|
impl TryFrom<Row> for Item {
|
||||||
type Error = ParseError;
|
type Error = ParseError;
|
||||||
|
|
||||||
|
@ -66,10 +72,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();
|
||||||
|
@ -88,18 +94,17 @@ fn convert_value(v: u16) -> f32 {
|
||||||
(value * 10.0).round() / 10.0
|
(value * 10.0).round() / 10.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fix the timestamps of the items either before or after the day boundary.
|
/// Fix the timestamps of the items either before or after the day boundary with respect to now.
|
||||||
///
|
///
|
||||||
/// If in the Europe/Amsterdam time zone it is still before 0:00, all timestamps after 0:00 need to
|
/// If in the Europe/Amsterdam time zone it is still before 0:00, all timestamps after 0:00 need to
|
||||||
/// be bumped up with a day. If it is already after 0:00, all timestamps before 0:00 need to be
|
/// be bumped up with a day. If it is already after 0:00, all timestamps before 0:00 need to be
|
||||||
/// bumped back with a day.
|
/// bumped back with a day.
|
||||||
// TODO: If something in Sinoptik needs unit tests, it is this!
|
fn fix_items_day_boundary(items: Vec<Item>, now: DateTime<Tz>) -> Vec<Item> {
|
||||||
fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
|
|
||||||
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 +112,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 +123,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 +133,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 +160,46 @@ 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))
|
let now = Utc::now().with_timezone(&Europe::Amsterdam);
|
||||||
|
|
||||||
|
Ok(fix_items_day_boundary(items, now))
|
||||||
} 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 +207,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 +224,72 @@ 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_items_day_boundary() {
|
||||||
|
let t_0 = Utc.with_ymd_and_hms(2024, 1, 10, 22, 0, 0).unwrap(); // 2024-1-10 22:00:00
|
||||||
|
let t_1 = Utc.with_ymd_and_hms(2024, 1, 10, 23, 0, 0).unwrap(); // 2024-1-10 23:00:00
|
||||||
|
let t_2 = Utc.with_ymd_and_hms(2024, 1, 10, 2, 0, 0).unwrap(); // 2024-1-10 2:00:00
|
||||||
|
|
||||||
|
// The first and last item are on the same day as now (at 21:55).
|
||||||
|
let now = Utc
|
||||||
|
.with_ymd_and_hms(2024, 1, 10, 21, 55, 0)
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Europe::Amsterdam);
|
||||||
|
let items = Vec::from([
|
||||||
|
Item::new(t_0, 2.9),
|
||||||
|
/* Items in between do not matter */
|
||||||
|
Item::new(t_1, 3.0),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
super::fix_items_day_boundary(items, now),
|
||||||
|
Vec::from([Item::new(t_0, 2.9), Item::new(t_1, 3.0)])
|
||||||
|
);
|
||||||
|
|
||||||
|
// The last item is on the next day (2024-1-11) with respect to now (at 21:55).
|
||||||
|
let now = Utc
|
||||||
|
.with_ymd_and_hms(2024, 1, 10, 21, 55, 0)
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Europe::Amsterdam);
|
||||||
|
let items = Vec::from([
|
||||||
|
Item::new(t_0, 2.9),
|
||||||
|
/* Items in between do not matter */
|
||||||
|
Item::new(t_2, 3.0),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
super::fix_items_day_boundary(items, now),
|
||||||
|
Vec::from([
|
||||||
|
Item::new(t_0, 2.9),
|
||||||
|
Item::new(t_2.with_day(11).unwrap(), 3.0)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// The first item is on the previous day (2024-1-9) with respect to now (at 1:55).
|
||||||
|
let now = Utc
|
||||||
|
.with_ymd_and_hms(2024, 1, 10, 1, 55, 0)
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Europe::Amsterdam);
|
||||||
|
let items = Vec::from([
|
||||||
|
Item::new(t_0, 2.9),
|
||||||
|
/* Items in between do not matter */
|
||||||
|
Item::new(t_2, 3.0),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
super::fix_items_day_boundary(items, now),
|
||||||
|
Vec::from([
|
||||||
|
Item::new(t_0.with_day(9).unwrap(), 2.9),
|
||||||
|
Item::new(t_2, 3.0)
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,70 +56,59 @@ 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;
|
||||||
|
|
||||||
// Only retain samples/items that have timestamps that are at least half an hour ago.
|
// Only retain samples/items that have timestamps that are at least an hour ago.
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
pollen_samples.retain(|smp| smp.time.signed_duration_since(now).num_seconds() > -1800);
|
pollen_samples.retain(|smp| smp.time.signed_duration_since(now).num_seconds() > -3600);
|
||||||
aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -1800);
|
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
|
||||||
|
.iter()
|
||||||
|
.position(|smp| {
|
||||||
smp.time
|
smp.time
|
||||||
.signed_duration_since(aqi_first_time)
|
.signed_duration_since(aqi_first_time)
|
||||||
.num_seconds()
|
.num_seconds()
|
||||||
.abs()
|
.abs()
|
||||||
< 1800
|
< 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
|
||||||
|
.iter()
|
||||||
|
.position(|item| {
|
||||||
item.time
|
item.time
|
||||||
.signed_duration_since(pollen_first_time)
|
.signed_duration_since(pollen_first_time)
|
||||||
.num_seconds()
|
.num_seconds()
|
||||||
.abs()
|
.abs()
|
||||||
< 1800
|
< 1800
|
||||||
})?;
|
})
|
||||||
|
.ok_or(MergeError::NoClosePollenItemFound)?;
|
||||||
aqi_items.drain(..idx);
|
aqi_items.drain(..idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the maximum sample/item of each series.
|
|
||||||
// Note: Unwrapping is possible because each series has at least an item otherwise `.first`
|
|
||||||
// would have failed above.
|
|
||||||
let pollen_max = pollen_samples
|
|
||||||
.iter()
|
|
||||||
.max_by_key(|sample| sample.score)
|
|
||||||
.cloned()
|
|
||||||
.unwrap();
|
|
||||||
let aqi_max = aqi_items
|
|
||||||
.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);
|
||||||
|
@ -107,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)]
|
||||||
|
@ -181,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([
|
||||||
|
@ -191,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..]
|
||||||
|
@ -204,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..]
|
||||||
|
@ -220,11 +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.
|
||||||
|
let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone());
|
||||||
|
assert!(merged.is_ok());
|
||||||
|
let paqi = merged.unwrap();
|
||||||
|
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
|
||||||
|
|
||||||
|
let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec());
|
||||||
|
assert!(merged.is_ok());
|
||||||
|
let paqi = merged.unwrap();
|
||||||
|
assert_eq!(paqi, Vec::from([Item::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
|
||||||
|
@ -236,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue