├── .dockerignore
├── .github
├── FUNDING.yml
└── workflows
│ └── main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── brightsky
├── __init__.py
├── __main__.py
├── cli.py
├── db.py
├── enhancements.py
├── export.py
├── parsers.py
├── polling.py
├── query.py
├── settings.py
├── tasks.py
├── utils.py
├── web
│ ├── __init__.py
│ ├── app.py
│ ├── intro.md
│ ├── models.py
│ └── params.py
└── worker.py
├── docker-compose.yml
├── docs
├── CNAME
├── apple-touch-icon.png
├── brightsky.yml
├── demo
│ ├── alerts
│ │ ├── cells.json
│ │ └── index.html
│ ├── cities.json
│ ├── img
│ │ ├── arrow_down.svg
│ │ └── arrow_right.svg
│ ├── index.html
│ └── radar
│ │ ├── index.html
│ │ ├── js-colormaps.js
│ │ ├── pause.svg
│ │ └── play.svg
├── docs
│ ├── elements-styles.min.css
│ ├── elements-web-components.min.js
│ └── index.html
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.svg
├── img
│ ├── architecture.svg
│ ├── bmbf.svg
│ ├── book.svg
│ ├── coffee.svg
│ ├── dwd.svg
│ ├── eye.svg
│ ├── github.svg
│ ├── heart.svg
│ ├── okfde.svg
│ └── pf.svg
└── index.html
├── migrations
├── 0001_migrations.sql
├── 0002_parsed_files.sql
├── 0003_weather.sql
├── 0004_station_name.sql
├── 0005_additional_weather_params.sql
├── 0006_wmo_station_ids.sql
├── 0007_synop.sql
├── 0008_current_weather.sql
├── 0009_sources_date_range.sql
├── 0010_weather_index_performance.sql
├── 0011_weather_condition.sql
├── 0012_remove_legacy_recent_records.sql
├── 0013_precipitation_probability.sql
├── 0014_solar.sql
├── 0015_radar.sql
├── 0016_alerts.sql
├── 0017_fix_ll_to_earth.sql
└── 0018_alerts_status.sql
├── pyproject.toml
├── requirements-dev.in
├── requirements-dev.txt
├── requirements.txt
├── scripts
├── benchmark.py
├── benchmark_compression.py
├── benchmark_radar.py
├── equal_responses.py
└── radar_coordinates.py
└── tests
├── __init__.py
├── conftest.py
├── data
├── 10minutenwerte_SOLAR_01766_akt.zip
├── DE1200_RV2305081330.tar.bz2
├── Meta_Daten_zehn_min_sd_01766.zip
├── Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_MUL.zip
├── alert_cells.json
├── dwd_opendata_index.html
├── observations_current.csv
├── observations_recent_FF_akt.zip
└── station_list.html
├── test_db.py
├── test_export.py
├── test_parsers.py
├── test_polling.py
├── test_settings.py
├── test_tasks.py
├── test_utils.py
├── test_web.py
└── utils.py
/.dockerignore:
--------------------------------------------------------------------------------
1 | .gitignore
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: jdemaeyer
2 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | tags: [ 'v*' ]
7 | pull_request:
8 | branches: [ master ]
9 |
10 | jobs:
11 | test:
12 | name: Run Test Suite
13 | runs-on: ubuntu-latest
14 | services:
15 | postgres:
16 | image: postgres:16-alpine
17 | env:
18 | POSTGRES_PASSWORD: pgpass
19 | options: >-
20 | --health-cmd pg_isready
21 | --health-interval 10s
22 | --health-timeout 5s
23 | --health-retries 5
24 | ports:
25 | - 5432:5432
26 | steps:
27 | - uses: actions/checkout@v4
28 | - name: Set up Python 3.13
29 | uses: actions/setup-python@v5
30 | with:
31 | python-version: '3.13'
32 | - name: Run tests
33 | env:
34 | BRIGHTSKY_DATABASE_URL: postgres://postgres:pgpass@localhost/brightsky_test
35 | run: |
36 | python -m pip install -r requirements-dev.txt
37 | ruff check .
38 | pytest
39 |
40 | push-to-pypi:
41 | name: Push Python Package to PyPI
42 | if: startsWith(github.ref, 'refs/tags/v')
43 | needs: test
44 | runs-on: ubuntu-latest
45 | permissions:
46 | id-token: write
47 | steps:
48 | - uses: actions/checkout@v4
49 | - name: Set up Python 3.13
50 | uses: actions/setup-python@v5
51 | with:
52 | python-version: '3.13'
53 | - name: Build Package
54 | run: |
55 | python -m pip install --upgrade pip
56 | python -m pip install --upgrade --user build
57 | python -m build
58 | - name: Push Package to PyPI
59 | uses: pypa/gh-action-pypi-publish@release/v1
60 |
61 | push-to-dockerhub:
62 | name: Push Docker Image to Docker Hub
63 | if: startsWith(github.ref, 'refs/tags/v')
64 | needs: test
65 | runs-on: ubuntu-latest
66 | steps:
67 | - uses: actions/checkout@v4
68 | - name: Set up QEMU
69 | uses: docker/setup-qemu-action@v3
70 | with:
71 | platforms: 'arm64'
72 | - name: Set up Docker Buildx
73 | uses: docker/setup-buildx-action@v3
74 | - name: Login to Docker Hub
75 | uses: docker/login-action@v3
76 | with:
77 | username: ${{ secrets.DOCKER_USERNAME }}
78 | password: ${{ secrets.DOCKER_ACCESSTOKEN }}
79 | - name: Build Image and Push to Docker Hub
80 | uses: docker/build-push-action@v5
81 | with:
82 | push: true
83 | tags: jdemaeyer/brightsky:${{ github.ref_name }},jdemaeyer/brightsky:latest
84 | platforms: linux/amd64,linux/arm64
85 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .data
3 | .env
4 | .tox
5 | .venv
6 | build
7 | dist
8 | *.egg-info
9 | *.pyc
10 | docker-compose.override.yml
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.13-slim
2 |
3 | WORKDIR /app
4 |
5 | ENV PYTHONFAULTHANDLER=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
6 |
7 | COPY requirements.txt .
8 |
9 | RUN pip install -r requirements.txt
10 |
11 | COPY migrations migrations
12 | COPY brightsky brightsky
13 | COPY pyproject.toml .
14 | COPY README.md .
15 |
16 | RUN pip install .
17 |
18 | ENTRYPOINT ["python", "-m", "brightsky"]
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jakob de Maeyer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Bright Sky
4 |
5 | [](https://api.brightsky.dev/)
6 | [](https://brightsky.dev/docs/)
7 | [](https://github.com/jdemaeyer/brightsky/actions)
8 | [](https://pypi.org/project/brightsky/)
9 | [](https://hub.docker.com/r/jdemaeyer/brightsky)
10 |
11 | ### JSON API for DWD's open weather data.
12 |
13 | The DWD ([Deutscher Wetterdienst](https://www.dwd.de/)), as Germany's
14 | meteorological service, publishes a myriad of meteorological observations and
15 | calculations as part of their [Open Data
16 | program](https://www.dwd.de/DE/leistungen/opendata/opendata.html).
17 |
18 | [**Bright Sky**](https://brightsky.dev/) is an open-source project aiming to
19 | make some of the more popular data — in particular weather observations from
20 | the DWD station network and weather forecasts from the MOSMIX model — available
21 | in a free, simple JSON API.
22 |
23 |
24 | ### Looking for something specific?
25 |
26 | #### I just want to retrieve some weather data
27 |
28 | You can use the free [public Bright Sky instance](https://brightsky.dev/)!
29 |
30 | #### I want to run my own instance of Bright Sky
31 |
32 | Check out the [infrastructure
33 | repo](https://github.com/jdemaeyer/brightsky-infrastructure/)!
34 |
35 | #### I want to parse DWD weather files from the command line or in Python
36 |
37 | The parsing core for Bright Sky is maintained in a separate package named
38 | [`dwdparse`](https://github.com/jdemaeyer/dwdparse), which has no dependencies
39 | outside the standard library. If you find that's not quite serving your needs,
40 | check out [`wetterdienst`](https://github.com/earthobservations/wetterdienst).
41 |
42 | #### I want to contribute to Bright Sky's source code
43 |
44 | Read on. :)
45 |
46 |
47 | ### On Bright Sky's versioning
48 |
49 | Starting from version 2.0, where we extracted the parsing core into a [separate
50 | package](https://github.com/jdemaeyer/dwdparse), Bright Sky is **no longer
51 | intended to be used as a Python library**, but only as the service available at
52 | [`brightsky.dev`](https://brightsky.dev/).
53 |
54 | Consequentially, we adjust our version numbers from the _perspective of that
55 | service and its users_ – i.e., we will increase the major version number only
56 | when we introduce backwards-incompatible (or otherwise very major) changes to
57 | the actual JSON API interface, e.g. by changing URLs or parameters. This means
58 | that **increases of the minor version number may introduce
59 | backwards-incompatible changes to the internals of the `brightsky` package,
60 | including the database structure**. If you use `brightsky` as a Python library,
61 | please version-pin to a minor version, e.g. by putting `brightsky==2.0.*` in
62 | your `requirements.txt`.
63 |
64 |
65 | ## Quickstart
66 |
67 | ### Running a full-fledged API instance
68 |
69 | _Note: These instructions are aimed at running a Bright Sky instance for
70 | development and testing. Check out our [infrastructure
71 | repository](https://github.com/jdemaeyer/brightsky-infrastructure/) if you want
72 | to set up a production-level API instance._
73 |
74 | Just run `docker-compose up` and you should be good to go. This will set up a
75 | PostgreSQL database (with persistent storage in `.data`), run a Redis server,
76 | and start the Bright Sky worker and webserver. The worker periodically polls
77 | the DWD Open Data Server for updates, parses them, and stores them in the
78 | database. The webserver will be listening to API requests on port 5000.
79 |
80 |
81 | ## Architecture
82 |
83 | 
84 |
85 | Bright Sky is a rather simple project consisting of four components:
86 |
87 | * The `brightsky` worker, which leverages the logic contained in the
88 | `brightsky` Python package to retrieve weather records from the DWD server,
89 | parse them, and store them in a database. It will periodically poll the DWD
90 | servers for new data.
91 |
92 | * The `brightsky` webserver (API), which serves as gate to our database and
93 | processes all queries for weather records coming from the outside world.
94 |
95 | * A PostgreSQL database consisting of two relevant tables:
96 |
97 | * `sources` contains information on the locations for which we hold weather
98 | records, and
99 | * `weather` contains the history of actual meteorological measurements (or
100 | forecasts) for these locations.
101 |
102 | The database structure can be set up by running the `migrate` command, which
103 | will simply apply all `.sql` files found in the `migrations` folder.
104 |
105 | * A Redis server, which is used as the backend of the worker's task queue.
106 |
107 | Most of the tasks performed by the worker and webserver can also be performed
108 | independently. Run `docker-compose run --rm brightsky` to get a list of
109 | available commands.
110 |
111 |
112 | ## Hacking
113 |
114 | Constantly rebuilding the `brightsky` container while working on the code can
115 | become cumbersome, and the default setting of parsing records dating all the
116 | way back to 2010 will make your development database unnecessarily large. You
117 | can set up a more lightweight development environment as follows:
118 |
119 | 1. Create a virtual environment and install our dependencies:
120 | `python -m virtualenv .venv && source .venv/bin/activate && pip install -r
121 | requirements.txt && pip install -e .`
122 |
123 | 2. Start a PostgreSQL container:
124 | `docker-compose run --rm -p 5432:5432 postgres`
125 |
126 | 3. Start a Redis container:
127 | `docker-compose run --rm -p 6379:6379 redis`
128 |
129 | 4. Point `brightsky` to your containers, and configure a tighter date
130 | threshold for parsing DWD data, by adding the following `.env` file:
131 | ```
132 | BRIGHTSKY_DATABASE_URL=postgres://postgres:pgpass@localhost
133 | BRIGHTSKY_BENCHMARK_DATABASE_URL=postgres://postgres:pgpass@localhost/benchmark
134 | BRIGHTSKY_REDIS_URL=redis://localhost
135 | BRIGHTSKY_MIN_DATE=2020-01-01
136 | ```
137 |
138 | You should now be able to directly run `brightsky` commands via `python -m
139 | brightsky`, and changes to the source code should be effective immediately.
140 |
141 |
142 | ### Tests
143 |
144 | Large parts of our test suite run against a real Postgres database. By default,
145 | these tests will be skipped. To enable them, make sure the
146 | `BRIGHTSKY_DATABASE_URL` environment variable is set when calling `pytest`,
147 | e.g. via:
148 | ```
149 | BRIGHTSKY_DATABASE_URL=postgres://postgres:pgpass@localhost/brightsky_test pytest
150 | ```
151 |
152 | Beware that adding this environment variable to your `.env` file will not work
153 | as that file is not read by `pytest`. The database will be **dropped and
154 | recreated** on every test run, so don't use your normal Bright Sky database. ;)
155 |
156 |
157 | ## Acknowledgements
158 |
159 | Bright Sky's development is boosted by the priceless guidance and support of
160 | the [Open Knowledge Foundation](https://www.okfn.de/)'s [Prototype
161 | Fund](https://prototypefund.de/) program, and is generously funded by Germany's
162 | [Federal Ministry of Education and Research](https://www.bmbf.de/). Obvious as
163 | it may be, it should be mentioned that none of this would be possible without
164 | the painstaking, never-ending effort of the [Deutscher
165 | Wetterdienst](https://www.dwd.de/).
166 |
167 |
168 |
169 |
170 |
171 |
--------------------------------------------------------------------------------
/brightsky/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = '2.2.6'
2 |
--------------------------------------------------------------------------------
/brightsky/__main__.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from brightsky import __version__
4 | from brightsky.cli import cli
5 | from brightsky.utils import configure_logging, load_dotenv
6 |
7 |
8 | def _getenv_float(key):
9 | x = os.getenv(key)
10 | return float(x) if x else None
11 |
12 |
13 | if __name__ == '__main__':
14 | load_dotenv()
15 | configure_logging()
16 | if os.getenv('SENTRY_DSN'):
17 | import sentry_sdk
18 | sentry_sdk.init(
19 | dsn=os.getenv('SENTRY_DSN'),
20 | release=__version__,
21 | traces_sample_rate=_getenv_float('SENTRY_TRACES_SAMPLE_RATE'),
22 | profiles_sample_rate=_getenv_float('SENTRY_PROFILES_SAMPLE_RATE'),
23 | )
24 | cli(prog_name='python -m brightsky')
25 |
--------------------------------------------------------------------------------
/brightsky/cli.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 |
4 | import click
5 | import uvicorn
6 | from fastapi.testclient import TestClient
7 | from huey.consumer_options import ConsumerConfig
8 |
9 | from brightsky import db, tasks
10 | from brightsky.utils import parse_date
11 | from brightsky.web import app
12 | from brightsky.worker import huey
13 |
14 |
15 | def dump_records(it):
16 | for record in it:
17 | print(json.dumps(record, default=str))
18 |
19 |
20 | def migrate_callback(ctx, param, value):
21 | if value:
22 | db.migrate()
23 |
24 |
25 | def parse_date_arg(ctx, param, value):
26 | if not value:
27 | return
28 | return parse_date(value)
29 |
30 |
31 | @click.group()
32 | @click.option(
33 | '--migrate', help='Migrate database before running command.',
34 | is_flag=True, is_eager=True, expose_value=False, callback=migrate_callback)
35 | def cli():
36 | pass
37 |
38 |
39 | @cli.command()
40 | def migrate():
41 | """Apply all pending database migrations."""
42 | db.migrate()
43 |
44 |
45 | @cli.command()
46 | @click.argument(
47 | 'targets',
48 | required=True,
49 | nargs=-1,
50 | metavar='TARGET [TARGET ...]',
51 | )
52 | def parse(targets):
53 | for target in targets:
54 | tasks.parse(target)
55 |
56 |
57 | @cli.command()
58 | @click.option(
59 | '--enqueue/--no-enqueue', default=False,
60 | help='Enqueue updated files for processing by the worker')
61 | def poll(enqueue):
62 | """Detect updated files on DWD Open Data Server."""
63 | files = tasks.poll(enqueue=enqueue)
64 | if not enqueue:
65 | dump_records(files)
66 |
67 |
68 | @cli.command()
69 | def clean():
70 | """Clean expired forecast and observations from database."""
71 | tasks.clean()
72 |
73 |
74 | @cli.command()
75 | @click.option('--workers', default=3, type=int, help='Number of threads')
76 | def work(workers):
77 | """Start brightsky worker."""
78 | huey.flush()
79 | config = ConsumerConfig(worker_type='thread', workers=workers)
80 | config.validate()
81 | consumer = huey.create_consumer(**config.values)
82 | consumer.run()
83 |
84 |
85 | @cli.command()
86 | @click.option('--bind', default='127.0.0.1:5000', help='Bind address')
87 | @click.option(
88 | '--reload/--no-reload', default=False,
89 | help='Reload server on source code changes')
90 | @click.option('--workers', default=1, type=int, help='Number of workers')
91 | def serve(bind, reload, workers):
92 | """Start brightsky API webserver."""
93 | host, port = bind.rsplit(':', 1)
94 | uvicorn.run(
95 | 'brightsky.web:app',
96 | host=host,
97 | port=int(port),
98 | reload=reload,
99 | workers=workers,
100 | )
101 |
102 |
103 | @cli.command(context_settings={'ignore_unknown_options': True})
104 | @click.argument('endpoint')
105 | @click.argument('parameters', nargs=-1, type=click.UNPROCESSED)
106 | def query(endpoint, parameters):
107 | """Query API and print JSON response.
108 |
109 | Parameters must be supplied as --name value or --name=value. See
110 | https://brightsky.dev/docs/ for the available endpoints and arguments.
111 |
112 | \b
113 | Examples:
114 | python -m brightsky query weather --lat 52 --lon 7.6 --date 2018-08-13
115 | python -m brightsky query current_weather --lat=52 --lon=7.6
116 | """
117 | for route in app.routes:
118 | if route.path == f'/{endpoint}':
119 | break
120 | else:
121 | raise click.UsageError(f"Unknown endpoint '{endpoint}'")
122 | logging.getLogger().setLevel(logging.WARNING)
123 | with TestClient(app) as client:
124 | resp = client.get(f'/{endpoint}', params=_parse_params(parameters))
125 | print(json.dumps(resp.json()))
126 |
127 |
128 | def _parse_params(parameters):
129 | # I'm sure there's a function in click or argparse somewhere that does this
130 | # but I can't find it
131 | usage = "Supply API parameters as --name value or --name=value"
132 | params = {}
133 | param_name = None
134 | for param in parameters:
135 | if param_name is None:
136 | if not param.startswith('--'):
137 | raise click.UsageError(usage)
138 | param = param[2:]
139 | if '=' in param:
140 | name, value = param.split('=', 1)
141 | params[name] = value
142 | else:
143 | param_name = param
144 | else:
145 | params[param_name] = param
146 | param_name = None
147 | if param_name is not None:
148 | raise click.UsageError(usage)
149 | return params
150 |
--------------------------------------------------------------------------------
/brightsky/db.py:
--------------------------------------------------------------------------------
1 | import glob
2 | import logging
3 | import os
4 | import re
5 | from contextlib import contextmanager, suppress
6 |
7 | import psycopg2
8 | from psycopg2.extras import DictCursor
9 | from psycopg2.pool import ThreadedConnectionPool
10 |
11 | from brightsky.settings import settings
12 |
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | @contextmanager
18 | def get_connection():
19 | if not hasattr(get_connection, '_pool'):
20 | maxconn = 1 if 'gunicorn' in os.getenv('SERVER_SOFTWARE', '') else 100
21 | get_connection._pool = ThreadedConnectionPool(
22 | 1, maxconn, settings.DATABASE_URL, cursor_factory=DictCursor)
23 | pool = get_connection._pool
24 | conn = pool.getconn()
25 | try:
26 | with conn:
27 | yield conn
28 | except psycopg2.InterfaceError:
29 | logger.warning('Discarding dead connection pool')
30 | pool.closeall()
31 | del get_connection._pool
32 | raise
33 | finally:
34 | if not pool.closed:
35 | pool.putconn(conn)
36 |
37 |
38 | def fetch(*args, **kwargs):
39 | for retry in range(5):
40 | with suppress(psycopg2.InterfaceError):
41 | with get_connection() as conn:
42 | with conn.cursor() as cur:
43 | cur.execute(*args, **kwargs)
44 | return cur.fetchall()
45 |
46 |
47 | def migrate():
48 | logger.info("Migrating database")
49 | with get_connection() as conn:
50 | with conn.cursor() as cur:
51 | try:
52 | cur.execute('SELECT MAX(id) FROM migrations;')
53 | except psycopg2.errors.UndefinedTable:
54 | conn.rollback()
55 | latest_migration = 0
56 | else:
57 | latest_migration = cur.fetchone()[0]
58 |
59 | migration_paths = [
60 | f for f in sorted(glob.glob('migrations/*.sql'))
61 | if (m := re.match(r'(\d+)_', os.path.basename(f)))
62 | and int(m.group(1)) > latest_migration
63 | ]
64 |
65 | for path in migration_paths:
66 | logger.info("Applying %s", path)
67 | match = re.match(r'(\d+)_?(.*)\.sql', os.path.basename(path))
68 | migration_id = int(match.group(1))
69 | migration_name = match.group(2)
70 | with open(path) as f:
71 | cur.execute(f.read())
72 | cur.execute(
73 | 'INSERT INTO migrations (id, name) VALUES (%s, %s);',
74 | (migration_id, migration_name))
75 | conn.commit()
76 | if migration_paths:
77 | logger.info("Applied %d migrations", len(migration_paths))
78 | else:
79 | logger.info("No new migrations")
80 |
--------------------------------------------------------------------------------
/brightsky/enhancements.py:
--------------------------------------------------------------------------------
1 | from dwdparse.units import convert_record
2 |
3 | from brightsky.settings import settings
4 | from brightsky.utils import daytime
5 |
6 |
7 | def enhance(result, timezone=None, units='si'):
8 | if 'sources' in result:
9 | enhance_sources(result['sources'], timezone=timezone)
10 | if 'weather' in result:
11 | source_map = {s['id']: s for s in result['sources']}
12 | enhance_records(
13 | result['weather'],
14 | source_map,
15 | timezone=timezone,
16 | units=units,
17 | )
18 | if 'radar' in result:
19 | enhance_radar(result['radar'], timezone=timezone)
20 | if 'alerts' in result:
21 | enhance_alerts(result['alerts'], timezone=timezone)
22 |
23 |
24 | def enhance_records(records, source_map, timezone=None, units='si'):
25 | if not isinstance(records, list):
26 | records = [records]
27 | for record in records:
28 | record['icon'] = get_icon(record, source_map)
29 | if units != 'si':
30 | convert_record(record, units)
31 | process_timestamp(record, 'timestamp', timezone)
32 |
33 |
34 | def enhance_sources(sources, timezone=None):
35 | for source in sources:
36 | process_timestamp(source, 'first_record', timezone)
37 | process_timestamp(source, 'last_record', timezone)
38 |
39 |
40 | def enhance_radar(radar, timezone=None):
41 | for record in radar:
42 | process_timestamp(record, 'timestamp', timezone)
43 |
44 |
45 | def enhance_alerts(alerts, timezone=None):
46 | for alert in alerts:
47 | for key in ['effective', 'onset', 'expires']:
48 | process_timestamp(alert, key, timezone)
49 |
50 |
51 | def process_timestamp(o, key, timezone):
52 | if not timezone:
53 | return
54 | elif not o[key]:
55 | return
56 | o[key] = o[key].astimezone(timezone)
57 |
58 |
59 | def get_icon(record, source_map):
60 | if record['condition'] in (
61 | 'fog', 'sleet', 'snow', 'hail', 'thunderstorm'):
62 | return record['condition']
63 | try:
64 | precipitation = record['precipitation']
65 | except KeyError:
66 | precipitation = record['precipitation_10']
67 | try:
68 | wind_speed = record['wind_speed']
69 | except KeyError:
70 | wind_speed = record['wind_speed_10']
71 | # Don't show 'rain' icon for little precipitation, and do show 'rain'
72 | # icon when condition is None but there is significant precipitation
73 | is_rainy = (
74 | record['condition'] == 'rain' and precipitation is None) or (
75 | (precipitation or 0) > settings.ICON_RAIN_THRESHOLD)
76 | if is_rainy:
77 | return 'rain'
78 | elif (wind_speed or 0) > settings.ICON_WIND_THRESHOLD:
79 | return 'wind'
80 | elif (record['cloud_cover'] or 0) >= settings.ICON_CLOUDY_THRESHOLD:
81 | return 'cloudy'
82 | source = source_map[record['source_id']]
83 | daytime_str = daytime(source['lat'], source['lon'], record['timestamp'])
84 | if (record['cloud_cover'] or 0) >= settings.ICON_PARTLY_CLOUDY_THRESHOLD:
85 | return f'partly-cloudy-{daytime_str}'
86 | return f'clear-{daytime_str}'
87 |
--------------------------------------------------------------------------------
/brightsky/parsers.py:
--------------------------------------------------------------------------------
1 | import csv
2 | import datetime
3 | import re
4 |
5 | import dwdparse.parsers
6 | import numpy as np
7 | from dateutil.tz import tzutc
8 | from isal import isal_zlib as zlib
9 |
10 | from brightsky.db import fetch
11 | from brightsky.export import (
12 | AlertExporter,
13 | DBExporter,
14 | RADOLANExporter,
15 | SYNOPExporter,
16 | )
17 | from brightsky.settings import settings
18 |
19 |
20 | class BrightSkyMixin:
21 |
22 | PRIORITY = 10
23 | exporter = DBExporter
24 |
25 | def skip_path(self, path):
26 | return False
27 |
28 |
29 | class ObservationsBrightSkyMixin(BrightSkyMixin):
30 |
31 | def skip_path(self, path):
32 | if (m := re.search(r'_(\d{8})_(\d{8})_hist\.zip$', str(path))):
33 | end_date = datetime.datetime.strptime(
34 | m.group(2),
35 | '%Y%m%d',
36 | ).replace(tzinfo=tzutc())
37 | if end_date < settings.MIN_DATE:
38 | return True
39 | if settings.MAX_DATE:
40 | start_date = datetime.datetime.strptime(
41 | m.group(1), '%Y%m%d').replace(tzinfo=tzutc())
42 | if start_date > settings.MAX_DATE:
43 | return True
44 | return False
45 |
46 | def skip_timestamp(self, timestamp):
47 | if timestamp < settings.MIN_DATE:
48 | return True
49 | elif settings.MAX_DATE and timestamp > settings.MAX_DATE:
50 | return True
51 | return False
52 |
53 |
54 | class MOSMIXParser(BrightSkyMixin, dwdparse.parsers.MOSMIXParser):
55 |
56 | PRIORITY = 20
57 |
58 |
59 | class SYNOPParser(BrightSkyMixin, dwdparse.parsers.SYNOPParser):
60 |
61 | PRIORITY = 30
62 | exporter = SYNOPExporter
63 |
64 |
65 | class CurrentObservationsParser(
66 | BrightSkyMixin,
67 | dwdparse.parsers.CurrentObservationsParser,
68 | ):
69 |
70 | PRIORITY = 30
71 |
72 | def skip_path(self, path):
73 | return path.endswith(tuple(
74 | f'{station:_<5}-BEOB.csv'
75 | for station in settings.IGNORED_CURRENT_OBSERVATIONS_STATIONS
76 | ))
77 |
78 | def parse(self, path, lat=None, lon=None, height=None, station_name=None):
79 | if any(x is None for x in (lat, lon, height, station_name)):
80 | with open(path) as f:
81 | reader = csv.DictReader(f, delimiter=';')
82 | wmo_station_id = next(reader)[self.DATE_COLUMN].rstrip('_')
83 | lat, lon, height, station_name = self._load_location(
84 | wmo_station_id,
85 | )
86 | return super().parse(
87 | path,
88 | lat=lat,
89 | lon=lon,
90 | height=height,
91 | station_name=station_name,
92 | )
93 |
94 | def _load_location(self, wmo_station_id):
95 | rows = fetch(
96 | """
97 | SELECT lat, lon, height, station_name
98 | FROM sources
99 | WHERE wmo_station_id = %s
100 | ORDER BY observation_type DESC, id DESC
101 | LIMIT 1
102 | """,
103 | (wmo_station_id,),
104 | )
105 | if not rows:
106 | raise ValueError(f'Cannot find location for WMO {wmo_station_id}')
107 | return rows[0]
108 |
109 |
110 | class CloudCoverObservationsParser(
111 | ObservationsBrightSkyMixin,
112 | dwdparse.parsers.CloudCoverObservationsParser,
113 | ):
114 | pass
115 |
116 |
117 | class DewPointObservationsParser(
118 | ObservationsBrightSkyMixin,
119 | dwdparse.parsers.DewPointObservationsParser,
120 | ):
121 | pass
122 |
123 |
124 | class TemperatureObservationsParser(
125 | ObservationsBrightSkyMixin,
126 | dwdparse.parsers.TemperatureObservationsParser,
127 | ):
128 | pass
129 |
130 |
131 | class PrecipitationObservationsParser(
132 | ObservationsBrightSkyMixin,
133 | dwdparse.parsers.PrecipitationObservationsParser,
134 | ):
135 | pass
136 |
137 |
138 | class SolarRadiationObservationsParser(
139 | ObservationsBrightSkyMixin,
140 | dwdparse.parsers.SolarRadiationObservationsParser,
141 | ):
142 |
143 | def skip_timestamp(self, timestamp):
144 | # We aggregate solar radiation from ten-minute data, where the values
145 | # correspond to radiation for the NEXT ten minutes, i.e. the value
146 | # tagged 14:30 contains the solar radiation between 14:30 - 14:40 (I
147 | # have not found a place where this is officially documented, but this
148 | # interpretation makes the values align with the hourly data from the
149 | # 'current' sources).
150 | # This makes solar radiation the only parameter where the 'recent'
151 | # sources produce a data point for today, which is otherwise only
152 | # served by the 'current' sources. To avoid excessive fill-up when
153 | # querying today's weather, we ignore this last data point (but will
154 | # pick it up on the next day).
155 | if timestamp.date() == datetime.date.today():
156 | return True
157 | return super().skip_timestamp(timestamp)
158 |
159 |
160 | class VisibilityObservationsParser(
161 | ObservationsBrightSkyMixin,
162 | dwdparse.parsers.VisibilityObservationsParser,
163 | ):
164 | pass
165 |
166 |
167 | class WindObservationsParser(
168 | ObservationsBrightSkyMixin,
169 | dwdparse.parsers.WindObservationsParser,
170 | ):
171 | pass
172 |
173 |
174 | class WindGustsObservationsParser(
175 | ObservationsBrightSkyMixin,
176 | dwdparse.parsers.WindGustsObservationsParser,
177 | ):
178 | pass
179 |
180 |
181 | class SunshineObservationsParser(
182 | ObservationsBrightSkyMixin,
183 | dwdparse.parsers.SunshineObservationsParser,
184 | ):
185 | pass
186 |
187 |
188 | class PressureObservationsParser(
189 | ObservationsBrightSkyMixin,
190 | dwdparse.parsers.PressureObservationsParser,
191 | ):
192 | pass
193 |
194 |
195 | class RADOLANParser(BrightSkyMixin, dwdparse.parsers.RADOLANParser):
196 |
197 | PRIORITY = 30
198 | exporter = RADOLANExporter
199 |
200 | def process_raw_data(self, raw):
201 | # XXX: Unlike with the other weather parameters, because of it's large
202 | # size, we're storing the radar data in a half-raw state and
203 | # performing some final processing during runtime. This brings
204 | # down the response time for retrieving one full radar scan
205 | # (single timestamp, 1200x1100 pixels) from 1.5 seconds to about
206 | # 1 ms, mainly because of the reduced data transfer when fetching
207 | # the scan from the database. An important caveat of this is that
208 | # we are replacing `None` with `0`!
209 | data = np.array(raw, dtype='i2')
210 | data[data > 4095] = 0
211 | data = np.flipud(data.reshape((1200, 1100)))
212 | return zlib.compress(np.ascontiguousarray(data))
213 |
214 |
215 | class CAPParser(BrightSkyMixin, dwdparse.parsers.CAPParser):
216 |
217 | PRIORITY = 40
218 | exporter = AlertExporter
219 |
220 |
221 | def get_parser(filename):
222 | parsers = {
223 | r'DE1200_RV': RADOLANParser,
224 | r'MOSMIX_(S|L)_LATEST(_240)?\.kmz$': MOSMIXParser,
225 | r'Z_CAP_C_EDZW_LATEST_.*_COMMUNEUNION_MUL\.zip': CAPParser,
226 | r'Z__C_EDZW_\d+_.*\.json\.bz2$': SYNOPParser,
227 | r'\w{5}-BEOB\.csv$': CurrentObservationsParser,
228 | 'stundenwerte_FF_': WindObservationsParser,
229 | 'stundenwerte_N_': CloudCoverObservationsParser,
230 | 'stundenwerte_P0_': PressureObservationsParser,
231 | 'stundenwerte_RR_': PrecipitationObservationsParser,
232 | 'stundenwerte_SD_': SunshineObservationsParser,
233 | 'stundenwerte_TD_': DewPointObservationsParser,
234 | 'stundenwerte_TU_': TemperatureObservationsParser,
235 | 'stundenwerte_VV_': VisibilityObservationsParser,
236 | '10minutenwerte_extrema_wind_': WindGustsObservationsParser,
237 | '10minutenwerte_SOLAR_': SolarRadiationObservationsParser,
238 | }
239 | for pattern, parser in parsers.items():
240 | if re.match(pattern, filename):
241 | return parser
242 |
--------------------------------------------------------------------------------
/brightsky/polling.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import re
4 |
5 | import dateutil.parser
6 | import requests
7 | from dateutil.tz import tzutc
8 | from parsel import Selector
9 |
10 | from brightsky.db import fetch
11 | from brightsky.parsers import get_parser
12 |
13 |
14 | class DWDPoller:
15 |
16 | urls = [
17 | 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/'
18 | 'all_stations/kml/',
19 | 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/'
20 | 'all_stations/kml/',
21 | 'https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/',
22 | 'https://opendata.dwd.de/weather/radar/composite/rv/',
23 | 'https://opendata.dwd.de/weather/weather_reports/synoptic/germany/'
24 | 'json/',
25 | 'https://opendata.dwd.de/weather/weather_reports/poi/',
26 | ] + [
27 | 'https://opendata.dwd.de/climate_environment/CDC/observations_germany/'
28 | f'climate/hourly/{subfolder}/'
29 | for subfolder in [
30 | 'air_temperature', 'cloudiness', 'dew_point', 'visibility',
31 | 'precipitation', 'pressure', 'sun', 'wind']
32 | ] + [
33 | 'https://opendata.dwd.de/climate_environment/CDC/observations_germany/'
34 | f'climate/10_minutes/{param}/{subfolder}/'
35 | for param in ['extreme_wind', 'solar']
36 | for subfolder in ['recent', 'historical']
37 | ]
38 |
39 | @property
40 | def logger(self):
41 | if not hasattr(self, '_logger'):
42 | self._logger = logging.getLogger(self.__class__.__name__)
43 | return self._logger
44 |
45 | def poll(self):
46 | self.logger.info("Polling for updated files")
47 | parsed_files = {
48 | row['url']: row
49 | for row in fetch('SELECT * FROM parsed_files')
50 | }
51 | for url in self.urls:
52 | for file_info in self.poll_url(url):
53 | if not self.matches_known_fingerprint(parsed_files, file_info):
54 | yield file_info
55 |
56 | def poll_url(self, url):
57 | self.logger.debug("Loading %s", url)
58 | resp = requests.get(url)
59 | resp.raise_for_status()
60 | return self.parse(url, resp.text)
61 |
62 | def parse(self, url, resp_text):
63 | sel = Selector(resp_text)
64 | directories = []
65 | files = []
66 | for anchor_sel in sel.css('a'):
67 | link = anchor_sel.css('::attr(href)').extract_first()
68 | if link.startswith('.'):
69 | continue
70 | link_url = f'{url}{link}'
71 | if link.endswith('/'):
72 | directories.append(link_url)
73 | else:
74 | fingerprint = anchor_sel.xpath(
75 | './following-sibling::text()[1]').extract_first()
76 | match = re.match(
77 | r'\s*(\d+-\w+-\d+ \d+:\d+(:\d+)?)\s+(\d+)', fingerprint)
78 | last_modified = dateutil.parser.parse(
79 | match.group(1)).replace(tzinfo=tzutc())
80 | file_size = int(match.group(3))
81 | parser_cls = get_parser(link)
82 | if parser_cls and not parser_cls().skip_path(link):
83 | files.append({
84 | 'url': link_url,
85 | 'parser': parser_cls.__name__,
86 | 'last_modified': last_modified,
87 | 'file_size': file_size,
88 | })
89 | self.logger.debug(
90 | "Found %d directories and %d files at %s",
91 | len(directories), len(files), url)
92 | yield from files
93 | for dir_url in directories:
94 | yield from self.poll_url(dir_url)
95 |
96 | def matches_known_fingerprint(self, parsed_files, file_info):
97 | parsed_info = parsed_files.get(file_info['url'])
98 | if not parsed_info:
99 | return False
100 | last_modified_diff = abs(
101 | file_info['last_modified'] - parsed_info['last_modified'])
102 | # The downloaded file timestamp will sometimes be off from the index
103 | # page timestamp by one second, presumably because it is delivered from
104 | # a different server than the one that delivered the index page. If the
105 | # file was modified at 59 seconds past the minute this can lead to a
106 | # timestamp that is off by one minute (as seconds are not part of the
107 | # timestamp). To not re-process these files over and over we allow a
108 | # one minute time difference from the known fingerprint.
109 | return (
110 | file_info['file_size'] == parsed_info['file_size'] and
111 | last_modified_diff <= datetime.timedelta(minutes=1))
112 |
--------------------------------------------------------------------------------
/brightsky/settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | from multiprocessing import cpu_count
4 |
5 | from dateutil.tz import tzutc
6 |
7 | from brightsky.utils import load_dotenv
8 |
9 |
10 | CORS_ALLOW_ALL_ORIGINS = False
11 | CORS_ALLOW_ALL_HEADERS = False
12 | CORS_ALLOWED_ORIGINS = []
13 | CORS_ALLOWED_HEADERS = []
14 | DATABASE_CONNECTION_POOL_SIZE = cpu_count()
15 | DATABASE_URL = 'postgres://localhost'
16 | ICON_CLOUDY_THRESHOLD = 80
17 | ICON_PARTLY_CLOUDY_THRESHOLD = 25
18 | ICON_RAIN_THRESHOLD = 0.5
19 | ICON_WIND_THRESHOLD = 10.8
20 | IGNORED_CURRENT_OBSERVATIONS_STATIONS = ['K386']
21 | KEEP_DOWNLOADS = False
22 | MIN_DATE = datetime.datetime(2010, 1, 1, tzinfo=tzutc())
23 | MAX_DATE = None
24 | POLLING_CRONTAB_MINUTE = '*'
25 | REDIS_URL = 'redis://localhost'
26 | SERVER_URL = 'http://localhost:5000'
27 | WARN_CELLS_URL = (
28 | 'https://maps.dwd.de/geoserver/wfs'
29 | '?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature'
30 | '&TYPENAMES=Warngebiete_Gemeinden&OUTPUTFORMAT=json'
31 | )
32 |
33 |
34 | def _make_bool(bool_str):
35 | return bool_str == '1'
36 |
37 |
38 | def _make_date(date_str):
39 | return datetime.datetime.fromisoformat(date_str).replace(tzinfo=tzutc())
40 |
41 |
42 | def _make_list(list_str, separator=','):
43 | if not list_str:
44 | return []
45 | return list_str.split(separator)
46 |
47 |
48 | _SETTING_PARSERS = {
49 | 'MAX_DATE': _make_date,
50 |
51 | bool: _make_bool,
52 | datetime.datetime: _make_date,
53 | float: float,
54 | int: int,
55 | list: _make_list,
56 | }
57 |
58 |
59 | class Settings(dict):
60 | """A dictionary that makes its keys available as attributes"""
61 |
62 | def __init__(self, *args, **kwargs):
63 | super().__init__(*args, **kwargs)
64 | self.loaded = False
65 |
66 | def load(self):
67 | load_dotenv()
68 | for k, v in globals().items():
69 | if k.isupper() and not k.startswith('_'):
70 | self[k] = v
71 | for k, v in os.environ.items():
72 | if k.startswith('BRIGHTSKY_') and k.isupper():
73 | setting_name = k.split('_', 1)[1]
74 | setting_type = type(self.get(setting_name))
75 | setting_parser = _SETTING_PARSERS.get(
76 | setting_name, _SETTING_PARSERS.get(setting_type))
77 | if setting_parser:
78 | v = setting_parser(v)
79 | self[setting_name] = v
80 |
81 | def __getattr__(self, name):
82 | if not self.loaded:
83 | self.load()
84 | self.loaded = True
85 | return self[name]
86 |
87 |
88 | settings = Settings()
89 |
--------------------------------------------------------------------------------
/brightsky/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import tempfile
4 |
5 | from brightsky.db import get_connection
6 | from brightsky.parsers import get_parser
7 | from brightsky.polling import DWDPoller
8 | from brightsky.utils import download
9 | from brightsky.worker import huey, process
10 |
11 |
12 | logger = logging.getLogger('brightsky')
13 |
14 |
15 | def parse(url):
16 | parser = get_parser(os.path.basename(url))()
17 | with tempfile.TemporaryDirectory() as tmpdir:
18 | path, fingerprint = download(url, tmpdir)
19 | extra = {
20 | kwarg: download(extra_url, tmpdir)[0]
21 | for kwarg, extra_url in parser.get_extra_urls(path).items()
22 | }
23 | exporter = parser.exporter()
24 | exporter.export(parser.parse(path, **extra), fingerprint=fingerprint)
25 |
26 |
27 | def poll(enqueue=False):
28 | updated_files = DWDPoller().poll()
29 | if enqueue:
30 | if (expired_locks := huey.expire_locks(1800)):
31 | logger.warning(
32 | 'Removed expired locks: %s', ', '.join(expired_locks))
33 | pending_urls = [
34 | t.args[0] for t in huey.pending() if t.name == 'process']
35 | enqueued = 0
36 | for updated_file in updated_files:
37 | url = updated_file['url']
38 | if url in pending_urls:
39 | logger.debug('Skipping "%s": already queued', url)
40 | continue
41 | elif huey.is_locked(url):
42 | logger.debug('Skipping "%s": already running', url)
43 | continue
44 | logger.debug('Enqueueing "%s"', url)
45 | parser_cls = get_parser(os.path.basename(url))
46 | process(url, priority=parser_cls.PRIORITY)
47 | enqueued += 1
48 | queue_size = len([t for t in huey.pending() if t.name == 'process'])
49 | logger.info(
50 | 'Enqueued %d updated files for processing. Queue size: %d',
51 | enqueued,
52 | queue_size,
53 | )
54 | return updated_files
55 |
56 |
57 | def clean():
58 | expiry_intervals = {
59 | 'weather': {
60 | 'forecast': '3 hours',
61 | 'current': '48 hours',
62 | },
63 | 'synop': {
64 | 'synop': '30 hours',
65 | },
66 | }
67 | radar_expiry_interval = '6 hours'
68 | parsed_files_expiry_intervals = {
69 | '%/Z__C_EDZW_%': '1 week',
70 | '%/DE1200_RV%': '1 week',
71 | }
72 | with get_connection() as conn:
73 | with conn.cursor() as cur:
74 | logger.info(
75 | 'Deleting expired weather records: %s', expiry_intervals)
76 | for table, table_expires in expiry_intervals.items():
77 | for observation_type, interval in table_expires.items():
78 | cur.execute(
79 | f"""
80 | DELETE FROM {table} WHERE
81 | source_id IN (
82 | SELECT id FROM sources
83 | WHERE observation_type = %s) AND
84 | timestamp < current_timestamp - %s::interval;
85 | """,
86 | (observation_type, interval),
87 | )
88 | conn.commit()
89 | if cur.rowcount:
90 | logger.info(
91 | 'Deleted %d outdated %s weather records from %s',
92 | cur.rowcount, observation_type, table)
93 | cur.execute(
94 | f"""
95 | UPDATE sources SET
96 | first_record = record_range.first_record,
97 | last_record = record_range.last_record
98 | FROM (
99 | SELECT
100 | source_id,
101 | MIN(timestamp) AS first_record,
102 | MAX(timestamp) AS last_record
103 | FROM {table}
104 | GROUP BY source_id
105 | ) AS record_range
106 | WHERE sources.id = record_range.source_id;
107 | """)
108 | conn.commit()
109 | logger.info('Deleting expired radar records')
110 | cur.execute(
111 | """
112 | DELETE FROM radar WHERE
113 | timestamp < current_timestamp - %s::interval;
114 | """,
115 | (radar_expiry_interval,),
116 | )
117 | conn.commit()
118 | if cur.rowcount:
119 | logger.info('Deleted %d outdated radar records', cur.rowcount)
120 | logger.info(
121 | 'Deleting expired parsed files: %s',
122 | parsed_files_expiry_intervals)
123 | for filename, interval in parsed_files_expiry_intervals.items():
124 | cur.execute(
125 | """
126 | DELETE FROM parsed_files WHERE
127 | url LIKE %s AND
128 | parsed_at < current_timestamp - %s::interval;
129 | """,
130 | (filename, interval))
131 | conn.commit()
132 | if cur.rowcount:
133 | logger.info(
134 | 'Deleted %d outdated parsed files for pattern "%s"',
135 | cur.rowcount, filename)
136 |
--------------------------------------------------------------------------------
/brightsky/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from contextlib import suppress
4 | from functools import lru_cache
5 |
6 | import coloredlogs
7 | import dateutil.parser
8 | from astral import Observer
9 | from astral.sun import daylight
10 |
11 | import requests
12 |
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | USER_AGENT = 'Bright Sky / https://brightsky.dev/'
18 |
19 |
20 | def configure_logging():
21 | log_fmt = '%(asctime)s %(name)s %(levelname)s %(message)s'
22 | coloredlogs.install(level=logging.DEBUG, fmt=log_fmt)
23 | # Disable some third-party noise
24 | logging.getLogger('huey').setLevel(logging.INFO)
25 | logging.getLogger('urllib3').setLevel(logging.WARNING)
26 |
27 |
28 | def load_dotenv(path='.env'):
29 | if not int(os.getenv('BRIGHTSKY_LOAD_DOTENV', 1)):
30 | return
31 | with suppress(FileNotFoundError):
32 | with open(path) as f:
33 | for line in f:
34 | if line.strip() and not line.strip().startswith('#'):
35 | key, val = line.strip().split('=', 1)
36 | os.environ.setdefault(key, val)
37 |
38 |
39 | def download(url, directory):
40 | """
41 | Download a resource from `url` into `directory`, returning its path and
42 | fingerprint.
43 | """
44 | resp = requests.get(url, headers={'User-Agent': USER_AGENT})
45 | resp.raise_for_status()
46 | filename = os.path.basename(url)
47 | path = os.path.join(directory, filename)
48 | with open(path, 'wb') as f:
49 | f.write(resp.content)
50 | fingerprint = {
51 | 'url': url,
52 | 'last_modified': dateutil.parser.parse(resp.headers['Last-Modified']),
53 | 'file_size': int(resp.headers['Content-Length']),
54 | }
55 | return path, fingerprint
56 |
57 |
58 | def parse_date(date_str):
59 | try:
60 | return dateutil.parser.isoparse(date_str)
61 | except ValueError as e:
62 | # Auto-correct common error of not encoding '+' as '%2b' in URL
63 | handled_errors = [
64 | 'Inconsistent use of colon separator',
65 | 'Unused components in ISO string',
66 | ]
67 | if e.args and e.args[0] in handled_errors and date_str.count(' ') == 1:
68 | return parse_date(date_str.replace(' ', '+'))
69 | raise e from None
70 |
71 |
72 | @lru_cache
73 | def sunrise_sunset(lat, lon, date):
74 | return daylight(Observer(lat, lon), date)
75 |
76 |
77 | def daytime(lat, lon, timestamp):
78 | try:
79 | sunrise, sunset = sunrise_sunset(lat, lon, timestamp.date())
80 | except ValueError as e:
81 | return 'day' if 'above' in e.args[0] else 'night'
82 | if sunset < sunrise:
83 | return 'night' if sunset <= timestamp <= sunrise else 'day'
84 | return 'day' if sunrise <= timestamp <= sunset else 'night'
85 |
--------------------------------------------------------------------------------
/brightsky/web/__init__.py:
--------------------------------------------------------------------------------
1 | from .app import app, make_app
2 |
3 |
4 | __all__ = [
5 | 'app',
6 | 'make_app',
7 | ]
8 |
--------------------------------------------------------------------------------
/brightsky/web/intro.md:
--------------------------------------------------------------------------------
1 | Bright Sky is a free and open-source weather API. It aims to provide an easy-to-use gateway to weather data that the [DWD](https://www.dwd.de/) – Germany's meteorological service – publishes on their [open data server](https://opendata.dwd.de/).
2 |
3 | The public instance at `https://api.brightsky.dev/` is free-to-use for all purposes, **no API key required**! Please note that the [DWD's Terms of Use](https://www.dwd.de/EN/service/copyright/copyright_artikel.html) apply to all data you retrieve through the API.
4 |
5 | > This documentation is generated from an OpenAPI specification. The current version is available from https://api.brightsky.dev/openapi.json.
6 |
7 |
8 | ## Quickstart
9 |
10 | * Check out [`/current_weather`](operations/getCurrentWeather) if you want to know what the weather's like _right now_.
11 | * Check out [`/weather`](operations/getWeather) for hourly weather observations and forecasts.
12 | * Check out [`/radar`](operations/getRadar) if you're looking for a high-resolution rain radar.
13 | * Check out [`/alerts`](operations/getAlerts) if you're interested in weather alerts.
14 |
15 | ... or keep reading below for some background information.
16 |
17 |
18 | ## Good to Know
19 |
20 | * **Geographical coverage**: due to its nature as German meteorological service, the observations published by the DWD have a strong focus on Germany. The _forecasts_ cover the whole world, albeit at a much lower density outside of Germany.
21 | * **Historical coverage**: Bright Sky serves historical data going back to January 1st, 2010. If you need data that goes further back, check out our [infrastructure repository](https://github.com/jdemaeyer/brightsky-infrastructure) to easily set up your own instance of Bright Sky!
22 | * **Source IDs**: Bright Sky's _source IDs_ are a technical artifact and – unlike the [DWD station IDs](https://www.dwd.de/DE/leistungen/klimadatendeutschland/stationsliste.html) and [WMO station IDs](https://opendata.dwd.de/climate_environment/CDC/help/stations_list_CLIMAT_data.txt) – have no meaning in the real world. When making requests to Bright Sky, try to avoid them and supply lat/lon or station IDs instead.
23 |
24 |
25 | ## Useful Links
26 |
27 | * [Bright Sky source code and issue tracking](https://github.com/jdemaeyer/brightsky/)
28 | * [Bright Sky infrastructure configuration](https://github.com/jdemaeyer/brightsky-infrastructure/)
29 | * [DWD Open Data landing page](https://www.dwd.de/EN/ourservices/opendata/opendata.html)
30 | * [Additional explanation files for DWD Open Data](https://www.dwd.de/DE/leistungen/opendata/hilfe.html?nn=495490&lsbId=627548), including:
31 | * [List of main observation stations](https://www.dwd.de/DE/leistungen/opendata/help/stationen/ha_messnetz.xls?__blob=publicationFile&v=1)
32 | * [List of additional observation stations](https://www.dwd.de/DE/leistungen/opendata/help/stationen/na_messnetz.xlsx?__blob=publicationFile&v=10)
33 | * [List of MOSMIX stations](https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication&nn=495490)
34 | * [List of meteorological parameters](https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/kml/mosmix_elemente_pdf.pdf?__blob=publicationFile&v=2)
35 | * [DWD Open Data FAQ (German)](https://www.dwd.de/DE/leistungen/opendata/faqs_opendata.html)
36 | * [DWD Copyright information](https://www.dwd.de/EN/service/copyright/copyright_artikel.html)
37 |
38 |
39 | ## Data Sources
40 |
41 | All data available through Bright Sky is taken or derived from data on the [DWD open data server](https://opendata.dwd.de/):
42 |
43 | * **Current weather / SYNOP**:
44 | * https://opendata.dwd.de/weather/weather_reports/synoptic/germany/json/
45 | * **Hourly weather**:
46 | * Historical: https://opendata.dwd.de/climate_environment/CDC/observations_germany/climate/
47 | * Current day: https://opendata.dwd.de/weather/weather_reports/poi/
48 | * Forecasts: https://opendata.dwd.de/weather/local_forecasts/mos/
49 | * **Radar**:
50 | * https://opendata.dwd.de/weather/radar/composite/rv/
51 | * **Alerts**:
52 | * https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/
53 |
--------------------------------------------------------------------------------
/brightsky/worker.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import pathlib
3 | import resource
4 | import threading
5 | import time
6 |
7 | from dwdparse.stations import StationIDConverter, load_stations
8 | from dwdparse.utils import fetch
9 | from huey import crontab, PriorityRedisHuey
10 | from huey.api import TaskLock as TaskLock_
11 | from huey.exceptions import TaskLockedException
12 |
13 | from brightsky import tasks
14 | from brightsky.settings import settings
15 |
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | class ExpiringLocksHuey(PriorityRedisHuey):
21 |
22 | def lock_task(self, lock_name):
23 | return TaskLock(self, lock_name)
24 |
25 | def expire_locks(self, timeout):
26 | expired = set()
27 | threshold = time.time() - timeout
28 | for key in list(self._locks):
29 | value = self.get(key, peek=True)
30 | if value and float(value) < threshold:
31 | self.delete(key)
32 | expired.add(key)
33 | return expired
34 |
35 | def is_locked(self, lock_name):
36 | return TaskLock(self, lock_name).is_locked()
37 |
38 |
39 | class TaskLock(TaskLock_):
40 |
41 | def __enter__(self):
42 | if not self._huey.put_if_empty(self._key, str(time.time())):
43 | raise TaskLockedException('unable to set lock: %s' % self._name)
44 |
45 | def is_locked(self):
46 | return self._huey.storage.has_data_for_key(self._key)
47 |
48 |
49 | huey = ExpiringLocksHuey(
50 | 'brightsky',
51 | results=False,
52 | url=settings.REDIS_URL,
53 | )
54 |
55 |
56 | @huey.periodic_task(crontab(minute='42', hour='3'), priority=40)
57 | @huey.on_startup()
58 | def update_stations():
59 | path = pathlib.Path('.cache', 'stations.html')
60 | with update_stations._lock:
61 | if time.time() - update_stations._last_update < 60:
62 | return
63 | # On startup, skip download and load from cache if available
64 | if not update_stations._last_update and path.is_file():
65 | load_stations(path=path)
66 | else:
67 | try:
68 | path.parent.mkdir(parents=True, exist_ok=True)
69 | with open(path, 'wb') as f:
70 | f.write(fetch(StationIDConverter.STATION_LIST_URL))
71 | except OSError:
72 | # Probably missing permissions, load without caching
73 | load_stations()
74 | else:
75 | load_stations(path=path)
76 | update_stations._last_update = time.time()
77 | update_stations._lock = threading.Lock() # noqa: E305
78 | update_stations._last_update = 0
79 |
80 |
81 | @huey.task()
82 | def process(url):
83 | with huey.lock_task(url):
84 | tasks.parse(url)
85 |
86 |
87 | @huey.periodic_task(
88 | crontab(minute=settings.POLLING_CRONTAB_MINUTE), priority=50)
89 | def poll():
90 | tasks.poll(enqueue=True)
91 |
92 |
93 | @huey.periodic_task(crontab(minute='23'), priority=0)
94 | def clean():
95 | tasks.clean()
96 |
97 |
98 | @huey.periodic_task(crontab(), priority=100)
99 | def log_health():
100 | max_mem = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024)
101 | logger.info(f"Maximum memory usage: {max_mem:,} MiB")
102 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | x-brightsky:
2 | &brightsky
3 | build: .
4 | depends_on:
5 | - postgres
6 | - redis
7 | environment:
8 | BRIGHTSKY_DATABASE_URL: postgres://postgres:pgpass@postgres
9 | BRIGHTSKY_REDIS_URL: redis://redis
10 |
11 | services:
12 | postgres:
13 | image: postgres:16-alpine
14 | shm_size: 512mb
15 | environment:
16 | POSTGRES_PASSWORD: pgpass
17 | volumes:
18 | - .data:/var/lib/postgresql/data
19 | restart: unless-stopped
20 | redis:
21 | image: redis:5-alpine
22 | restart: unless-stopped
23 | worker:
24 | <<: *brightsky
25 | command: --migrate work
26 | restart: unless-stopped
27 | web:
28 | <<: *brightsky
29 | command: serve --bind 0.0.0.0:5000
30 | restart: unless-stopped
31 | ports:
32 | - 5000:5000
33 | brightsky:
34 | <<: *brightsky
35 | scale: 0
36 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | brightsky.dev
--------------------------------------------------------------------------------
/docs/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/docs/apple-touch-icon.png
--------------------------------------------------------------------------------
/docs/brightsky.yml:
--------------------------------------------------------------------------------
1 | THIS SPEC NOW LIVES AT https://api.brightsky.dev/openapi.json
2 |
--------------------------------------------------------------------------------
/docs/demo/alerts/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Bright Sky – Alerts demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
119 |
120 |
121 |
122 |
123 |
139 |
140 |
141 |
142 |
143 |
Live alerts:
144 |
145 |
190 |
191 |
192 |
193 |
256 |
257 |
258 |
--------------------------------------------------------------------------------
/docs/demo/img/arrow_down.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
78 |
--------------------------------------------------------------------------------
/docs/demo/img/arrow_right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
106 |
--------------------------------------------------------------------------------
/docs/demo/radar/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Bright Sky – Radar demo
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
78 |
79 |
80 |
81 |
241 |
242 |
243 |
--------------------------------------------------------------------------------
/docs/demo/radar/js-colormaps.js:
--------------------------------------------------------------------------------
1 |
2 | /*
3 | Add contents of data.js here:
4 | */
5 |
6 | const data = {"turbo": {"interpolate": true, "colors": [[0.19, 0.0718, 0.2322], [0.1948, 0.0834, 0.2615], [0.1996, 0.095, 0.2902], [0.2042, 0.1065, 0.3184], [0.2086, 0.118, 0.3461], [0.2129, 0.1295, 0.3731], [0.2171, 0.1409, 0.3996], [0.2211, 0.1522, 0.4256], [0.225, 0.1635, 0.451], [0.2288, 0.1748, 0.4758], [0.2324, 0.186, 0.5], [0.2358, 0.1972, 0.5237], [0.2392, 0.2083, 0.5469], [0.2423, 0.2194, 0.5694], [0.2454, 0.2304, 0.5914], [0.2483, 0.2414, 0.6129], [0.2511, 0.2524, 0.6337], [0.2537, 0.2633, 0.6541], [0.2562, 0.2741, 0.6738], [0.2585, 0.2849, 0.693], [0.2607, 0.2957, 0.7116], [0.2628, 0.3064, 0.7297], [0.2647, 0.3171, 0.7472], [0.2665, 0.3277, 0.7641], [0.2682, 0.3382, 0.7805], [0.2697, 0.3488, 0.7963], [0.271, 0.3593, 0.8116], [0.2723, 0.3697, 0.8262], [0.2733, 0.3801, 0.8404], [0.2743, 0.3904, 0.8539], [0.2751, 0.4007, 0.8669], [0.2758, 0.411, 0.8794], [0.2763, 0.4212, 0.8912], [0.2767, 0.4313, 0.9025], [0.2769, 0.4414, 0.9133], [0.277, 0.4515, 0.9235], [0.277, 0.4615, 0.9331], [0.2768, 0.4715, 0.9421], [0.2765, 0.4814, 0.9506], [0.276, 0.4913, 0.9586], [0.2754, 0.5012, 0.9659], [0.2747, 0.5109, 0.9728], [0.2738, 0.5207, 0.979], [0.2727, 0.5304, 0.9846], [0.2711, 0.5402, 0.9893], [0.2688, 0.55, 0.993], [0.2659, 0.5598, 0.9958], [0.2625, 0.5697, 0.9977], [0.2586, 0.5796, 0.9988], [0.2542, 0.5895, 0.999], [0.2495, 0.5994, 0.9984], [0.2443, 0.6094, 0.997], [0.2387, 0.6193, 0.9948], [0.2329, 0.6292, 0.992], [0.2268, 0.6391, 0.9885], [0.2204, 0.649, 0.9844], [0.2138, 0.6589, 0.9796], [0.2071, 0.6687, 0.9742], [0.2002, 0.6784, 0.9683], [0.1933, 0.6881, 0.9619], [0.1862, 0.6978, 0.955], [0.1792, 0.7073, 0.9476], [0.1722, 0.7168, 0.9398], [0.1653, 0.7262, 0.9316], [0.1584, 0.7355, 0.923], [0.1517, 0.7447, 0.9142], [0.1452, 0.7538, 0.905], [0.1389, 0.7628, 0.8955], [0.1328, 0.7716, 0.8858], [0.127, 0.7804, 0.8759], [0.1215, 0.789, 0.8658], [0.1164, 0.7974, 0.8556], [0.1117, 0.8057, 0.8452], [0.1074, 0.8138, 0.8348], [0.1036, 0.8218, 0.8244], [0.1003, 0.8296, 0.8139], [0.0975, 0.8371, 0.8034], [0.0953, 0.8446, 0.793], [0.0938, 0.8518, 0.7826], [0.0929, 0.8588, 0.7724], [0.0927, 0.8655, 0.7623], [0.0932, 0.8721, 0.7524], [0.0945, 0.8784, 0.7426], [0.0966, 0.8845, 0.7332], [0.0996, 0.8904, 0.7239], [0.1034, 0.896, 0.715], [0.1082, 0.9014, 0.706], [0.1137, 0.9067, 0.6965], [0.1201, 0.9119, 0.6866], [0.1273, 0.917, 0.6763], [0.1353, 0.922, 0.6656], [0.1439, 0.9268, 0.6545], [0.1532, 0.9315, 0.6431], [0.1632, 0.9361, 0.6314], [0.1738, 0.9405, 0.6194], [0.1849, 0.9448, 0.6071], [0.1966, 0.949, 0.5947], [0.2088, 0.953, 0.582], [0.2214, 0.9569, 0.5691], [0.2345, 0.9606, 0.5561], [0.248, 0.9642, 0.543], [0.2618, 0.9676, 0.5298], [0.276, 0.9709, 0.5165], [0.2904, 0.974, 0.5032], [0.3051, 0.977, 0.4899], [0.3201, 0.9797, 0.4765], [0.3352, 0.9823, 0.4632], [0.3504, 0.9848, 0.45], [0.3658, 0.987, 0.4369], [0.3813, 0.9891, 0.4239], [0.3968, 0.991, 0.411], [0.4123, 0.9927, 0.3983], [0.4278, 0.9942, 0.3858], [0.4432, 0.9955, 0.3734], [0.4585, 0.9966, 0.3614], [0.4738, 0.9976, 0.3496], [0.4888, 0.9983, 0.3382], [0.5036, 0.9988, 0.327], [0.5182, 0.9991, 0.3162], [0.5326, 0.9992, 0.3058], [0.5466, 0.9991, 0.2958], [0.5603, 0.9987, 0.2862], [0.5736, 0.9982, 0.2771], [0.5865, 0.9974, 0.2685], [0.5989, 0.9964, 0.2604], [0.6109, 0.9951, 0.2528], [0.6223, 0.9937, 0.2458], [0.6332, 0.992, 0.2394], [0.6436, 0.99, 0.2336], [0.6539, 0.9878, 0.2284], [0.6643, 0.9852, 0.2237], [0.6746, 0.9825, 0.2196], [0.6849, 0.9794, 0.216], [0.6952, 0.9761, 0.2129], [0.7055, 0.9726, 0.2103], [0.7158, 0.9688, 0.2082], [0.726, 0.9647, 0.2064], [0.7361, 0.9604, 0.205], [0.7462, 0.9559, 0.2041], [0.7562, 0.9512, 0.2034], [0.7661, 0.9463, 0.2031], [0.7759, 0.9411, 0.2031], [0.7856, 0.9358, 0.2034], [0.7952, 0.9302, 0.2039], [0.8047, 0.9245, 0.2046], [0.8141, 0.9186, 0.2055], [0.8233, 0.9125, 0.2066], [0.8324, 0.9063, 0.2079], [0.8413, 0.8999, 0.2093], [0.8501, 0.8933, 0.2107], [0.8587, 0.8866, 0.2123], [0.8671, 0.8797, 0.2139], [0.8753, 0.8727, 0.2156], [0.8833, 0.8655, 0.2172], [0.8911, 0.8583, 0.2188], [0.8987, 0.8509, 0.2204], [0.906, 0.8434, 0.2219], [0.9132, 0.8358, 0.2233], [0.92, 0.8281, 0.2246], [0.9267, 0.8202, 0.2257], [0.933, 0.8124, 0.2267], [0.9391, 0.8044, 0.2274], [0.9449, 0.7963, 0.228], [0.9504, 0.7882, 0.2283], [0.9556, 0.78, 0.2284], [0.9605, 0.7718, 0.2281], [0.9651, 0.7635, 0.2275], [0.9693, 0.7552, 0.2266], [0.9732, 0.7468, 0.2254], [0.9768, 0.7384, 0.2237], [0.98, 0.73, 0.2216], [0.9829, 0.7214, 0.2192], [0.9855, 0.7125, 0.2165], [0.9878, 0.7033, 0.2136], [0.9899, 0.6938, 0.2104], [0.9916, 0.6841, 0.2071], [0.9931, 0.6741, 0.2035], [0.9944, 0.6639, 0.1997], [0.9954, 0.6534, 0.1958], [0.9961, 0.6428, 0.1916], [0.9965, 0.6319, 0.1874], [0.9968, 0.6209, 0.183], [0.9967, 0.6098, 0.1784], [0.9964, 0.5985, 0.1738], [0.9959, 0.587, 0.169], [0.9952, 0.5755, 0.1641], [0.9942, 0.5639, 0.1592], [0.993, 0.5521, 0.1542], [0.9915, 0.5404, 0.1491], [0.9899, 0.5285, 0.144], [0.988, 0.5167, 0.1388], [0.9859, 0.5048, 0.1337], [0.9836, 0.4929, 0.1285], [0.9811, 0.481, 0.1233], [0.9784, 0.4692, 0.1182], [0.9754, 0.4574, 0.113], [0.9723, 0.4456, 0.108], [0.969, 0.434, 0.1029], [0.9656, 0.4224, 0.098], [0.9619, 0.4109, 0.0931], [0.958, 0.3996, 0.0883], [0.954, 0.3884, 0.0836], [0.9498, 0.3773, 0.079], [0.9454, 0.3664, 0.0746], [0.9408, 0.3557, 0.0703], [0.9361, 0.3451, 0.0662], [0.9312, 0.3348, 0.0622], [0.9262, 0.3247, 0.0584], [0.921, 0.3149, 0.0548], [0.9157, 0.3053, 0.0513], [0.9102, 0.296, 0.0481], [0.9046, 0.287, 0.0452], [0.8989, 0.2782, 0.0424], [0.893, 0.2698, 0.0399], [0.8869, 0.2615, 0.0375], [0.8807, 0.2533, 0.0352], [0.8742, 0.2453, 0.033], [0.8676, 0.2373, 0.0308], [0.8608, 0.2294, 0.0288], [0.8538, 0.2217, 0.0268], [0.8466, 0.2141, 0.0249], [0.8393, 0.2065, 0.023], [0.8317, 0.1991, 0.0213], [0.824, 0.1918, 0.0197], [0.8161, 0.1846, 0.0181], [0.808, 0.1775, 0.0166], [0.7997, 0.1706, 0.0152], [0.7912, 0.1637, 0.0139], [0.7826, 0.1569, 0.0126], [0.7738, 0.1503, 0.0115], [0.7648, 0.1437, 0.0104], [0.7556, 0.1373, 0.0094], [0.7462, 0.131, 0.0085], [0.7366, 0.1248, 0.0077], [0.7269, 0.1187, 0.007], [0.7169, 0.1127, 0.0063], [0.7068, 0.1068, 0.0057], [0.6965, 0.101, 0.0052], [0.686, 0.0954, 0.0048], [0.6754, 0.0898, 0.0045], [0.6645, 0.0844, 0.0042], [0.6534, 0.079, 0.0041], [0.6422, 0.0738, 0.004], [0.6308, 0.0687, 0.004], [0.6192, 0.0637, 0.0041], [0.6075, 0.0588, 0.0043], [0.5955, 0.054, 0.0045], [0.5834, 0.0493, 0.0049], [0.571, 0.0447, 0.0053], [0.5585, 0.0403, 0.0058], [0.5458, 0.0359, 0.0064], [0.533, 0.0317, 0.007], [0.5199, 0.0276, 0.0078], [0.5066, 0.0235, 0.0086], [0.4932, 0.0196, 0.0096], [0.4796, 0.0158, 0.0106]]}};
7 |
8 | const turbo = partial('turbo');
9 | const turbo_r = partial('turbo_r');
10 |
11 |
12 | /*
13 | Define auxiliary functions for evaluating colormaps
14 | */
15 |
16 | function evaluate_cmap(x, name, reverse) {
17 | /**
18 | * Evaluate colormap `name` at some value `x`.
19 | * @param {number} x - The value (between 0 and 1) at which to evaluate the colormap.
20 | * @param {string} name - The name of the colormap (see matplotlib documentation).
21 | * @reverse {boolean} reverse - Whether or not to reverse the colormap.
22 | * @return {list} - A 3-tuple (R, G, B) containing the color assigned to `x`.
23 | */
24 |
25 | // Ensure that the value of `x` is valid (i.e., 0 <= x <= 1)
26 | if (!(0 <= x <= 1)) {
27 | alert('Illegal value for x! Must be in [0, 1].')
28 | }
29 |
30 | // Ensure that `name` is a valid colormap
31 | if (!(name in data)) {
32 | alert('Colormap ' + name + 'does not exist!');
33 | }
34 |
35 | // We can get the reverse colormap by evaluating colormap(1-x)
36 | if (reverse === true) {
37 | x = 1 - x;
38 | }
39 |
40 | // Get the colors and whether or not we need to interpolate
41 | let colors = data[name]['colors'];
42 | let interpolate = data[name]['interpolate'];
43 |
44 | if (interpolate === true) {
45 | return interpolated(x, colors);
46 | } else {
47 | return qualitative(x, colors);
48 | }
49 | }
50 |
51 | function interpolated(x, colors) {
52 | let lo = Math.floor(x * (colors.length - 1));
53 | let hi = Math.ceil(x * (colors.length - 1));
54 | let r = Math.round((colors[lo][0] + colors[hi][0]) / 2 * 255);
55 | let g = Math.round((colors[lo][1] + colors[hi][1]) / 2 * 255);
56 | let b = Math.round((colors[lo][2] + colors[hi][2]) / 2 * 255);
57 | return [r, g, b];
58 | }
59 |
60 | function qualitative(x, colors) {
61 | let idx = 0;
62 | while (x > (idx + 1) / (colors.length - 0) ) { idx++; }
63 | let r = Math.round(colors[idx][0] * 255);
64 | let g = Math.round(colors[idx][1] * 255);
65 | let b = Math.round(colors[idx][2] * 255);
66 | return [r, g, b];
67 | }
68 |
69 | function partial(name) {
70 | if (name.endsWith('_r')) {
71 | return function(x) { return evaluate_cmap(x, name.substring(0, name.length - 2), true) };
72 | } else {
73 | return function(x) { return evaluate_cmap(x, name, false) };
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/docs/demo/radar/pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
46 |
--------------------------------------------------------------------------------
/docs/demo/radar/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
45 |
--------------------------------------------------------------------------------
/docs/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Bright Sky API Documentation
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
34 |
35 |
36 |
37 |
46 |
47 |
48 |
49 |
50 |
54 |
55 |
56 |
57 |
93 |
94 |
95 |
--------------------------------------------------------------------------------
/docs/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/docs/favicon-16x16.png
--------------------------------------------------------------------------------
/docs/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/docs/favicon-32x32.png
--------------------------------------------------------------------------------
/docs/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
107 |
--------------------------------------------------------------------------------
/docs/img/book.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/img/coffee.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
103 |
--------------------------------------------------------------------------------
/docs/img/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/img/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docs/img/heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
45 |
--------------------------------------------------------------------------------
/docs/img/pf.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
53 |
--------------------------------------------------------------------------------
/migrations/0001_migrations.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE migrations (
2 | id integer PRIMARY KEY,
3 | name varchar(255),
4 | applied timestamptz DEFAULT current_timestamp
5 | );
6 |
--------------------------------------------------------------------------------
/migrations/0002_parsed_files.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE parsed_files (
2 | url text PRIMARY KEY,
3 | last_modified timestamptz NOT NULL,
4 | file_size integer NOT NULL,
5 | parsed_at timestamptz NOT NULL
6 | );
7 |
--------------------------------------------------------------------------------
/migrations/0003_weather.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS cube WITH SCHEMA public;
2 | CREATE EXTENSION IF NOT EXISTS earthdistance WITH SCHEMA public;
3 | ALTER FUNCTION public.ll_to_earth SET search_path = public;
4 |
5 | CREATE TYPE observation_type AS ENUM ('historical', 'recent', 'current', 'forecast');
6 |
7 | CREATE TABLE sources (
8 | id serial PRIMARY KEY,
9 | station_id varchar(5) NOT NULL,
10 | observation_type observation_type NOT NULL,
11 | lat real NOT NULL,
12 | lon real NOT NULL,
13 | height real NOT NULL,
14 |
15 | CONSTRAINT weather_source_key UNIQUE (station_id, observation_type, lat, lon, height)
16 | );
17 |
18 | CREATE INDEX weather_source_location ON sources USING gist(ll_to_earth(lat, lon));
19 |
20 | CREATE TABLE weather (
21 | timestamp timestamptz NOT NULL,
22 | source_id int NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
23 | precipitation real CHECK (precipitation >= 0),
24 | pressure_msl integer CHECK (pressure_msl > 0),
25 | sunshine smallint CHECK (sunshine BETWEEN 0 and 3600),
26 | temperature real CHECK (temperature > 0),
27 | wind_direction smallint CHECK (wind_direction BETWEEN 0 AND 360),
28 | wind_speed real CHECK (wind_speed >= 0),
29 |
30 | CONSTRAINT weather_key UNIQUE (timestamp, source_id)
31 | );
32 |
--------------------------------------------------------------------------------
/migrations/0004_station_name.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE sources ADD COLUMN station_name varchar(255) NOT NULL DEFAULT '';
2 | ALTER TABLE sources ALTER COLUMN station_name DROP DEFAULT;
3 |
4 | -- Force full re-parse
5 | DELETE FROM parsed_files;
6 |
--------------------------------------------------------------------------------
/migrations/0005_additional_weather_params.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE weather
2 | ADD COLUMN cloud_cover smallint CHECK (cloud_cover BETWEEN 0 and 100),
3 | ADD COLUMN dew_point real CHECK (dew_point > 0),
4 | ADD COLUMN relative_humidity smallint CHECK (relative_humidity BETWEEN 0 and 100),
5 | ADD COLUMN visibility int CHECK (visibility >= 0),
6 | ADD COLUMN wind_gust_direction smallint CHECK (wind_gust_direction BETWEEN 0 AND 360),
7 | ADD COLUMN wind_gust_speed real CHECK (wind_gust_speed >= 0);
8 |
9 | -- relative_humidity is parsed from air temperature files
10 | DELETE FROM parsed_files WHERE url ILIKE '%stundenwerte_TU_%'
11 |
--------------------------------------------------------------------------------
/migrations/0006_wmo_station_ids.sql:
--------------------------------------------------------------------------------
1 | -- There are a bunch of stations only differing in their DWD station ID but not
2 | -- their observation_type or location. We'll have to decide how to deal with
3 | -- these during parsing.
4 | DELETE
5 | FROM sources s
6 | USING sources s2
7 | WHERE
8 | s.observation_type = s2.observation_type AND
9 | s.lat = s2.lat AND
10 | s.lon = s2.lon AND
11 | s.height = s2.height AND
12 | s.id > s2.id;
13 |
14 | ALTER TABLE sources
15 | RENAME COLUMN station_id TO dwd_station_id;
16 | ALTER TABLE sources
17 | ALTER COLUMN dwd_station_id DROP NOT NULL,
18 | ADD COLUMN wmo_station_id varchar(5),
19 | DROP CONSTRAINT weather_source_key,
20 | ADD CONSTRAINT weather_source_key UNIQUE (observation_type, lat, lon, height);
21 |
--------------------------------------------------------------------------------
/migrations/0007_synop.sql:
--------------------------------------------------------------------------------
1 | ALTER TYPE observation_type ADD VALUE 'synop' BEFORE 'forecast';
2 |
3 | CREATE TABLE synop (
4 | timestamp timestamptz NOT NULL,
5 | source_id int NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
6 |
7 | cloud_cover smallint CHECK (cloud_cover BETWEEN 0 and 100),
8 | dew_point real CHECK (dew_point > 0),
9 | precipitation_10 real CHECK (precipitation_10 >= 0),
10 | precipitation_30 real CHECK (precipitation_30 >= 0),
11 | precipitation_60 real CHECK (precipitation_60 >= 0),
12 | pressure_msl integer CHECK (pressure_msl > 0),
13 | relative_humidity smallint CHECK (relative_humidity BETWEEN 0 and 100),
14 | sunshine_10 smallint CHECK (sunshine_10 BETWEEN 0 and 600),
15 | sunshine_30 smallint CHECK (sunshine_30 BETWEEN 0 and 1800),
16 | sunshine_60 smallint CHECK (sunshine_60 BETWEEN 0 and 3600),
17 | temperature real CHECK (temperature > 0),
18 | visibility int CHECK (visibility >= 0),
19 | wind_direction_10 smallint CHECK (wind_direction_10 BETWEEN 0 AND 360),
20 | wind_direction_30 smallint CHECK (wind_direction_30 BETWEEN 0 AND 360),
21 | wind_direction_60 smallint CHECK (wind_direction_60 BETWEEN 0 AND 360),
22 | wind_speed_10 real CHECK (wind_speed_10 >= 0),
23 | wind_speed_30 real CHECK (wind_speed_30 >= 0),
24 | wind_speed_60 real CHECK (wind_speed_60 >= 0),
25 | wind_gust_direction_10 smallint CHECK (wind_gust_direction_10 BETWEEN 0 AND 360),
26 | wind_gust_direction_30 smallint CHECK (wind_gust_direction_30 BETWEEN 0 AND 360),
27 | wind_gust_direction_60 smallint CHECK (wind_gust_direction_60 BETWEEN 0 AND 360),
28 | wind_gust_speed_10 real CHECK (wind_gust_speed_10 >= 0),
29 | wind_gust_speed_30 real CHECK (wind_gust_speed_30 >= 0),
30 | wind_gust_speed_60 real CHECK (wind_gust_speed_60 >= 0),
31 |
32 | CONSTRAINT synop_key UNIQUE (timestamp, source_id)
33 | );
34 |
--------------------------------------------------------------------------------
/migrations/0008_current_weather.sql:
--------------------------------------------------------------------------------
1 | CREATE FUNCTION last_agg ( anyelement, anyelement )
2 | RETURNS anyelement LANGUAGE SQL IMMUTABLE STRICT AS $$
3 | SELECT $2;
4 | $$;
5 |
6 | CREATE AGGREGATE LAST ( anyelement ) (
7 | sfunc = last_agg,
8 | stype = anyelement
9 | );
10 |
11 | CREATE MATERIALIZED VIEW current_weather AS
12 | WITH last_timestamp AS (
13 | SELECT
14 | source_id,
15 | MAX(timestamp) AS last_timestamp
16 | FROM synop
17 | GROUP BY source_id
18 | )
19 | SELECT
20 | last_timestamp.source_id,
21 | last_timestamp.last_timestamp AS timestamp,
22 | latest.cloud_cover,
23 | latest.dew_point,
24 | latest.precipitation_10,
25 | last_half_hour.precipitation_30,
26 | last_hour.precipitation_60,
27 | latest.pressure_msl,
28 | latest.relative_humidity,
29 | latest.visibility,
30 | latest.wind_direction_10,
31 | last_half_hour.wind_direction_30,
32 | last_hour.wind_direction_60,
33 | latest.wind_speed_10,
34 | last_half_hour.wind_speed_30,
35 | last_hour.wind_speed_60,
36 | latest.wind_gust_direction_10,
37 | last_half_hour.wind_gust_direction_30,
38 | last_hour.wind_gust_direction_60,
39 | latest.wind_gust_speed_10,
40 | last_half_hour.wind_gust_speed_30,
41 | last_hour.wind_gust_speed_60,
42 | sunshine.sunshine_30,
43 | sunshine.sunshine_60,
44 | latest.temperature
45 | FROM last_timestamp
46 | JOIN (
47 | SELECT
48 | source_id,
49 | LAST(cloud_cover ORDER BY timestamp) AS cloud_cover,
50 | LAST(dew_point ORDER BY timestamp) AS dew_point,
51 | LAST(precipitation_10 ORDER BY timestamp) AS precipitation_10,
52 | LAST(pressure_msl ORDER BY timestamp) AS pressure_msl,
53 | LAST(relative_humidity ORDER BY timestamp) AS relative_humidity,
54 | LAST(visibility ORDER BY timestamp) AS visibility,
55 | LAST(wind_direction_10 ORDER BY timestamp) AS wind_direction_10,
56 | LAST(wind_speed_10 ORDER BY timestamp) AS wind_speed_10,
57 | LAST(wind_gust_direction_10 ORDER BY timestamp) AS wind_gust_direction_10,
58 | LAST(wind_gust_speed_10 ORDER BY timestamp) AS wind_gust_speed_10,
59 | LAST(temperature ORDER BY timestamp) AS temperature
60 | FROM synop s
61 | WHERE timestamp >= now() - '90 minutes'::interval
62 | GROUP BY source_id
63 | ) latest ON last_timestamp.source_id = latest.source_id
64 | LEFT JOIN (
65 | SELECT
66 | synop.source_id,
67 | round(AVG(precipitation_10) * 6 * 100) / 100 AS precipitation_60,
68 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_60,
69 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_60,
70 | MAX(wind_gust_speed_10) AS wind_gust_speed_60,
71 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_60
72 | FROM synop
73 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
74 | WHERE timestamp > last_timestamp - '60 minutes'::interval
75 | GROUP BY synop.source_id
76 | ) last_hour ON latest.source_id = last_hour.source_id
77 | LEFT JOIN (
78 | SELECT
79 | synop.source_id,
80 | round(AVG(precipitation_10) * 3 * 100) / 100 AS precipitation_30,
81 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_30,
82 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_30,
83 | MAX(wind_gust_speed_10) AS wind_gust_speed_30,
84 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_30
85 | FROM synop
86 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
87 | WHERE timestamp > last_timestamp - '30 minutes'::interval
88 | GROUP BY synop.source_id
89 | ) last_half_hour ON latest.source_id = last_half_hour.source_id
90 | LEFT JOIN (
91 | SELECT
92 | s30_latest.source_id,
93 | CASE
94 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30
95 | ELSE s60.sunshine_60 - s30_latest.sunshine_30
96 | END AS sunshine_30,
97 | CASE
98 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30 + s60.sunshine_60 - s30_previous.sunshine_30
99 | ELSE s60.sunshine_60
100 | END AS sunshine_60
101 | FROM (
102 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_30
103 | FROM synop
104 | WHERE sunshine_30 IS NOT NULL
105 | ORDER BY source_id, timestamp DESC
106 | ) s30_latest
107 | JOIN (
108 | SELECT source_id, timestamp, sunshine_30
109 | FROM synop
110 | ) s30_previous ON
111 | s30_latest.source_id = s30_previous.source_id AND
112 | s30_previous.timestamp = s30_latest.timestamp - '1 hour'::interval
113 | JOIN (
114 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_60
115 | FROM synop
116 | WHERE sunshine_60 IS NOT NULL
117 | ORDER BY source_id, timestamp DESC
118 | ) s60 ON
119 | s30_previous.source_id = s60.source_id AND
120 | s60.timestamp > s30_previous.timestamp
121 | ) sunshine ON latest.source_id = sunshine.source_id
122 | ORDER BY latest.source_id;
123 |
124 | CREATE UNIQUE INDEX current_weather_key ON current_weather (source_id);
125 |
--------------------------------------------------------------------------------
/migrations/0009_sources_date_range.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE sources
2 | ADD COLUMN first_record timestamptz,
3 | ADD COLUMN last_record timestamptz;
4 |
5 | UPDATE sources SET
6 | first_record = record_range.first_record,
7 | last_record = record_range.last_record
8 | FROM (
9 | SELECT
10 | source_id,
11 | MIN(timestamp) AS first_record,
12 | MAX(timestamp) AS last_record
13 | FROM weather
14 | GROUP BY source_id
15 | UNION
16 | SELECT
17 | source_id,
18 | MIN(timestamp) AS first_record,
19 | MAX(timestamp) AS last_record
20 | FROM synop
21 | GROUP BY source_id
22 | ) AS record_range
23 | WHERE sources.id = record_range.source_id;
24 |
--------------------------------------------------------------------------------
/migrations/0010_weather_index_performance.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE weather
2 | DROP CONSTRAINT weather_key,
3 | ADD CONSTRAINT weather_key UNIQUE (source_id, timestamp);
4 |
--------------------------------------------------------------------------------
/migrations/0011_weather_condition.sql:
--------------------------------------------------------------------------------
1 | CREATE TYPE weather_condition AS ENUM (
2 | 'dry',
3 | 'fog',
4 | 'rain',
5 | 'sleet',
6 | 'snow',
7 | 'hail',
8 | 'thunderstorm'
9 | );
10 |
11 | ALTER TABLE weather ADD COLUMN condition weather_condition;
12 | ALTER TABLE synop ADD COLUMN condition weather_condition;
13 |
14 | -- Re-parse all precipitation records
15 | DELETE FROM parsed_files WHERE url ILIKE '%stundenwerte_RR_%';
16 |
17 | -- Same query as before with condition field added
18 | DROP MATERIALIZED VIEW current_weather;
19 | CREATE MATERIALIZED VIEW current_weather AS
20 | WITH last_timestamp AS (
21 | SELECT
22 | source_id,
23 | MAX(timestamp) AS last_timestamp
24 | FROM synop
25 | GROUP BY source_id
26 | )
27 | SELECT
28 | last_timestamp.source_id,
29 | last_timestamp.last_timestamp AS timestamp,
30 | latest.cloud_cover,
31 | latest.condition,
32 | latest.dew_point,
33 | latest.precipitation_10,
34 | last_half_hour.precipitation_30,
35 | last_hour.precipitation_60,
36 | latest.pressure_msl,
37 | latest.relative_humidity,
38 | latest.visibility,
39 | latest.wind_direction_10,
40 | last_half_hour.wind_direction_30,
41 | last_hour.wind_direction_60,
42 | latest.wind_speed_10,
43 | last_half_hour.wind_speed_30,
44 | last_hour.wind_speed_60,
45 | latest.wind_gust_direction_10,
46 | last_half_hour.wind_gust_direction_30,
47 | last_hour.wind_gust_direction_60,
48 | latest.wind_gust_speed_10,
49 | last_half_hour.wind_gust_speed_30,
50 | last_hour.wind_gust_speed_60,
51 | sunshine.sunshine_30,
52 | sunshine.sunshine_60,
53 | latest.temperature
54 | FROM last_timestamp
55 | JOIN (
56 | SELECT
57 | source_id,
58 | LAST(cloud_cover ORDER BY timestamp) AS cloud_cover,
59 | LAST(condition ORDER BY timestamp) AS condition,
60 | LAST(dew_point ORDER BY timestamp) AS dew_point,
61 | LAST(precipitation_10 ORDER BY timestamp) AS precipitation_10,
62 | LAST(pressure_msl ORDER BY timestamp) AS pressure_msl,
63 | LAST(relative_humidity ORDER BY timestamp) AS relative_humidity,
64 | LAST(visibility ORDER BY timestamp) AS visibility,
65 | LAST(wind_direction_10 ORDER BY timestamp) AS wind_direction_10,
66 | LAST(wind_speed_10 ORDER BY timestamp) AS wind_speed_10,
67 | LAST(wind_gust_direction_10 ORDER BY timestamp) AS wind_gust_direction_10,
68 | LAST(wind_gust_speed_10 ORDER BY timestamp) AS wind_gust_speed_10,
69 | LAST(temperature ORDER BY timestamp) AS temperature
70 | FROM synop s
71 | WHERE timestamp >= now() - '90 minutes'::interval
72 | GROUP BY source_id
73 | ) latest ON last_timestamp.source_id = latest.source_id
74 | LEFT JOIN (
75 | SELECT
76 | synop.source_id,
77 | round(AVG(precipitation_10) * 6 * 100) / 100 AS precipitation_60,
78 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_60,
79 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_60,
80 | MAX(wind_gust_speed_10) AS wind_gust_speed_60,
81 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_60
82 | FROM synop
83 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
84 | WHERE timestamp > last_timestamp - '60 minutes'::interval
85 | GROUP BY synop.source_id
86 | ) last_hour ON latest.source_id = last_hour.source_id
87 | LEFT JOIN (
88 | SELECT
89 | synop.source_id,
90 | round(AVG(precipitation_10) * 3 * 100) / 100 AS precipitation_30,
91 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_30,
92 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_30,
93 | MAX(wind_gust_speed_10) AS wind_gust_speed_30,
94 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_30
95 | FROM synop
96 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
97 | WHERE timestamp > last_timestamp - '30 minutes'::interval
98 | GROUP BY synop.source_id
99 | ) last_half_hour ON latest.source_id = last_half_hour.source_id
100 | LEFT JOIN (
101 | SELECT
102 | s30_latest.source_id,
103 | CASE
104 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30
105 | ELSE s60.sunshine_60 - s30_latest.sunshine_30
106 | END AS sunshine_30,
107 | CASE
108 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30 + s60.sunshine_60 - s30_previous.sunshine_30
109 | ELSE s60.sunshine_60
110 | END AS sunshine_60
111 | FROM (
112 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_30
113 | FROM synop
114 | WHERE sunshine_30 IS NOT NULL
115 | ORDER BY source_id, timestamp DESC
116 | ) s30_latest
117 | JOIN (
118 | SELECT source_id, timestamp, sunshine_30
119 | FROM synop
120 | ) s30_previous ON
121 | s30_latest.source_id = s30_previous.source_id AND
122 | s30_previous.timestamp = s30_latest.timestamp - '1 hour'::interval
123 | JOIN (
124 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_60
125 | FROM synop
126 | WHERE sunshine_60 IS NOT NULL
127 | ORDER BY source_id, timestamp DESC
128 | ) s60 ON
129 | s30_previous.source_id = s60.source_id AND
130 | s60.timestamp > s30_previous.timestamp
131 | ) sunshine ON latest.source_id = sunshine.source_id
132 | ORDER BY latest.source_id;
133 |
134 | CREATE UNIQUE INDEX current_weather_key ON current_weather (source_id);
135 |
--------------------------------------------------------------------------------
/migrations/0012_remove_legacy_recent_records.sql:
--------------------------------------------------------------------------------
1 | -- This will delete all recent weather records by cascade
2 | DELETE FROM sources WHERE observation_type = 'recent';
3 |
--------------------------------------------------------------------------------
/migrations/0013_precipitation_probability.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE weather
2 | ADD COLUMN precipitation_probability smallint CHECK (precipitation_probability BETWEEN 0 and 100),
3 | ADD COLUMN precipitation_probability_6h smallint CHECK (precipitation_probability_6h BETWEEN 0 and 100);
4 |
--------------------------------------------------------------------------------
/migrations/0014_solar.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE weather
2 | ADD COLUMN solar integer CHECK (solar >= 0);
3 |
4 | ALTER TABLE synop
5 | ADD COLUMN solar_10 integer CHECK (solar_10 >= 0),
6 | ADD COLUMN solar_30 integer CHECK (solar_10 >= 0),
7 | ADD COLUMN solar_60 integer CHECK (solar_10 >= 0);
8 |
9 | -- Same query as before with solar_XX fields added
10 | DROP MATERIALIZED VIEW current_weather;
11 | CREATE MATERIALIZED VIEW current_weather AS
12 | WITH last_timestamp AS (
13 | SELECT
14 | source_id,
15 | MAX(timestamp) AS last_timestamp
16 | FROM synop
17 | GROUP BY source_id
18 | )
19 | SELECT
20 | last_timestamp.source_id,
21 | last_timestamp.last_timestamp AS timestamp,
22 | latest.cloud_cover,
23 | latest.condition,
24 | latest.dew_point,
25 | latest.solar_10,
26 | last_half_hour.solar_30,
27 | last_hour.solar_60,
28 | latest.precipitation_10,
29 | last_half_hour.precipitation_30,
30 | last_hour.precipitation_60,
31 | latest.pressure_msl,
32 | latest.relative_humidity,
33 | latest.visibility,
34 | latest.wind_direction_10,
35 | last_half_hour.wind_direction_30,
36 | last_hour.wind_direction_60,
37 | latest.wind_speed_10,
38 | last_half_hour.wind_speed_30,
39 | last_hour.wind_speed_60,
40 | latest.wind_gust_direction_10,
41 | last_half_hour.wind_gust_direction_30,
42 | last_hour.wind_gust_direction_60,
43 | latest.wind_gust_speed_10,
44 | last_half_hour.wind_gust_speed_30,
45 | last_hour.wind_gust_speed_60,
46 | sunshine.sunshine_30,
47 | sunshine.sunshine_60,
48 | latest.temperature
49 | FROM last_timestamp
50 | JOIN (
51 | SELECT
52 | source_id,
53 | LAST(cloud_cover ORDER BY timestamp) AS cloud_cover,
54 | LAST(condition ORDER BY timestamp) AS condition,
55 | LAST(dew_point ORDER BY timestamp) AS dew_point,
56 | LAST(solar_10 ORDER BY timestamp) AS solar_10,
57 | LAST(precipitation_10 ORDER BY timestamp) AS precipitation_10,
58 | LAST(pressure_msl ORDER BY timestamp) AS pressure_msl,
59 | LAST(relative_humidity ORDER BY timestamp) AS relative_humidity,
60 | LAST(visibility ORDER BY timestamp) AS visibility,
61 | LAST(wind_direction_10 ORDER BY timestamp) AS wind_direction_10,
62 | LAST(wind_speed_10 ORDER BY timestamp) AS wind_speed_10,
63 | LAST(wind_gust_direction_10 ORDER BY timestamp) AS wind_gust_direction_10,
64 | LAST(wind_gust_speed_10 ORDER BY timestamp) AS wind_gust_speed_10,
65 | LAST(temperature ORDER BY timestamp) AS temperature
66 | FROM synop s
67 | WHERE timestamp >= now() - '90 minutes'::interval
68 | GROUP BY source_id
69 | ) latest ON last_timestamp.source_id = latest.source_id
70 | LEFT JOIN (
71 | SELECT
72 | synop.source_id,
73 | round(AVG(solar_10) * 6)::int AS solar_60,
74 | round(AVG(precipitation_10) * 6 * 100) / 100 AS precipitation_60,
75 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_60,
76 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_60,
77 | MAX(wind_gust_speed_10) AS wind_gust_speed_60,
78 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_60
79 | FROM synop
80 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
81 | WHERE timestamp > last_timestamp - '60 minutes'::interval
82 | GROUP BY synop.source_id
83 | ) last_hour ON latest.source_id = last_hour.source_id
84 | LEFT JOIN (
85 | SELECT
86 | synop.source_id,
87 | round(AVG(solar_10) * 3)::int AS solar_30,
88 | round(AVG(precipitation_10) * 3 * 100) / 100 AS precipitation_30,
89 | round(AVG(wind_speed_10) * 10) / 10 AS wind_speed_30,
90 | (round(atan2d(AVG(sind(wind_direction_10)), AVG(cosd(wind_direction_10))))::int + 360) % 360 AS wind_direction_30,
91 | MAX(wind_gust_speed_10) AS wind_gust_speed_30,
92 | LAST(wind_gust_direction_10 ORDER BY wind_gust_speed_10) AS wind_gust_direction_30
93 | FROM synop
94 | JOIN last_timestamp ON synop.source_id = last_timestamp.source_id
95 | WHERE timestamp > last_timestamp - '30 minutes'::interval
96 | GROUP BY synop.source_id
97 | ) last_half_hour ON latest.source_id = last_half_hour.source_id
98 | LEFT JOIN (
99 | SELECT
100 | s30_latest.source_id,
101 | CASE
102 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30
103 | ELSE s60.sunshine_60 - s30_latest.sunshine_30
104 | END AS sunshine_30,
105 | CASE
106 | WHEN s30_latest.timestamp > s60.timestamp THEN s30_latest.sunshine_30 + s60.sunshine_60 - s30_previous.sunshine_30
107 | ELSE s60.sunshine_60
108 | END AS sunshine_60
109 | FROM (
110 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_30
111 | FROM synop
112 | WHERE sunshine_30 IS NOT NULL
113 | ORDER BY source_id, timestamp DESC
114 | ) s30_latest
115 | JOIN (
116 | SELECT source_id, timestamp, sunshine_30
117 | FROM synop
118 | ) s30_previous ON
119 | s30_latest.source_id = s30_previous.source_id AND
120 | s30_previous.timestamp = s30_latest.timestamp - '1 hour'::interval
121 | JOIN (
122 | SELECT DISTINCT ON (source_id) source_id, timestamp, sunshine_60
123 | FROM synop
124 | WHERE sunshine_60 IS NOT NULL
125 | ORDER BY source_id, timestamp DESC
126 | ) s60 ON
127 | s30_previous.source_id = s60.source_id AND
128 | s60.timestamp > s30_previous.timestamp
129 | ) sunshine ON latest.source_id = sunshine.source_id
130 | ORDER BY latest.source_id;
131 |
132 | CREATE UNIQUE INDEX current_weather_key ON current_weather (source_id);
133 |
--------------------------------------------------------------------------------
/migrations/0015_radar.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE radar (
2 | timestamp timestamptz NOT NULL,
3 |
4 | source varchar(255) NOT NULL,
5 | precipitation_5 bytea NOT NULL,
6 |
7 | CONSTRAINT radar_key UNIQUE (timestamp)
8 | );
9 |
--------------------------------------------------------------------------------
/migrations/0016_alerts.sql:
--------------------------------------------------------------------------------
1 | CREATE TYPE alert_category AS ENUM ('met', 'health');
2 | CREATE TYPE alert_response_type AS ENUM ('prepare', 'allclear', 'none', 'monitor');
3 | CREATE TYPE alert_urgency AS ENUM ('immediate', 'future');
4 | CREATE TYPE alert_severity AS ENUM ('minor', 'moderate', 'severe', 'extreme');
5 | CREATE TYPE alert_certainty AS ENUM ('observed', 'likely');
6 |
7 | CREATE TABLE alerts (
8 | id serial PRIMARY KEY,
9 | alert_id varchar(255) NOT NULL,
10 | effective timestamptz NOT NULL,
11 | onset timestamptz NOT NULL,
12 | expires timestamptz,
13 | category alert_category,
14 | response_type alert_response_type,
15 | urgency alert_urgency,
16 | severity alert_severity,
17 | certainty alert_certainty,
18 | event_code smallint CHECK (event_code BETWEEN 11 AND 248),
19 | event_en text,
20 | event_de text,
21 | headline_en text NOT NULL,
22 | headline_de text NOT NULL,
23 | description_en text NOT NULL,
24 | description_de text NOT NULL,
25 | instruction_en text,
26 | instruction_de text,
27 |
28 | CONSTRAINT alerts_key UNIQUE (alert_id)
29 | );
30 |
31 | CREATE TABLE alert_cells (
32 | alert_id int NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
33 | warn_cell_id int NOT NULL,
34 |
35 | CONSTRAINT alert_cells_key UNIQUE (warn_cell_id, alert_id)
36 | );
37 |
--------------------------------------------------------------------------------
/migrations/0017_fix_ll_to_earth.sql:
--------------------------------------------------------------------------------
1 | -- via https://github.com/diogob/activerecord-postgres-earthdistance/issues/30#issuecomment-1657757447
2 | -- Fixed issue seems to have had no impact on performance but produced spurious
3 | -- 'type "earth" does not exist' error messages
4 | ALTER FUNCTION ll_to_earth SET search_path = public;
5 |
--------------------------------------------------------------------------------
/migrations/0018_alerts_status.sql:
--------------------------------------------------------------------------------
1 | CREATE TYPE alert_status AS ENUM ('actual', 'test');
2 |
3 | ALTER TABLE alerts ADD COLUMN status alert_status NOT NULL DEFAULT 'actual';
4 | ALTER TABLE alerts ALTER COLUMN status DROP DEFAULT;
5 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "brightsky"
7 | dynamic = ["version"]
8 | requires-python = ">= 3.8"
9 | dependencies = [
10 | "astral",
11 | "asyncpg",
12 | "click",
13 | "coloredlogs",
14 | "dwdparse[lean]",
15 | "falcon>=3",
16 | "fastapi",
17 | "gunicorn",
18 | "httpx",
19 | "huey",
20 | "isal",
21 | "numpy",
22 | "orjson",
23 | "parsel",
24 | "psycopg2-binary",
25 | "pyproj",
26 | "python-dateutil",
27 | "redis",
28 | "requests",
29 | "sentry-sdk",
30 | "shapely",
31 | "uvicorn",
32 | ]
33 | authors = [
34 | {name = "Jakob de Maeyer", email = "jakob@brightsky.dev"},
35 | ]
36 | description = "JSON API for DWD's open weather data."
37 | readme = "README.md"
38 | license = {text = "MIT"}
39 | classifiers = [
40 | "Programming Language :: Python :: 3",
41 | "License :: OSI Approved :: MIT License",
42 | "Operating System :: OS Independent",
43 | ]
44 |
45 | [project.urls]
46 | Homepage = "https://brightsky.dev/"
47 | Documentation = "https://brightsky.dev/docs/"
48 | Source = "https://github.com/jdemaeyer/brightsky/"
49 | Tracker = "https://github.com/jdemaeyer/brightsky/issues/"
50 |
51 | [tool.setuptools.dynamic]
52 | version = {attr = "brightsky.__version__"}
53 |
54 | [tool.setuptools.packages.find]
55 | include = ["brightsky*"]
56 |
57 | [tool.pytest.ini_options]
58 | required_plugins = "pytest-env"
59 |
60 | [tool.pytest_env]
61 | BRIGHTSKY_LOAD_DOTENV = 0
62 |
63 | [tool.ruff]
64 | line-length = 79
65 |
66 | [tool.ruff.lint]
67 | select = ["E", "F"]
68 |
69 | [tool.ruff.lint.isort]
70 | lines-after-imports = 2
71 |
--------------------------------------------------------------------------------
/requirements-dev.in:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | freezegun
3 | pytest
4 | pytest-env
5 | ruff
6 | uv
7 | watchfiles
8 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | # This file was autogenerated by uv via the following command:
2 | # uv pip compile -o requirements-dev.txt requirements-dev.in
3 | annotated-types==0.7.0
4 | # via
5 | # -r requirements.txt
6 | # pydantic
7 | anyio==4.9.0
8 | # via
9 | # -r requirements.txt
10 | # httpx
11 | # starlette
12 | # watchfiles
13 | astral==3.2
14 | # via -r requirements.txt
15 | asyncpg==0.30.0
16 | # via -r requirements.txt
17 | certifi==2025.4.26
18 | # via
19 | # -r requirements.txt
20 | # httpcore
21 | # httpx
22 | # pyproj
23 | # requests
24 | # sentry-sdk
25 | charset-normalizer==3.4.1
26 | # via
27 | # -r requirements.txt
28 | # requests
29 | click==8.1.8
30 | # via
31 | # -r requirements.txt
32 | # uvicorn
33 | coloredlogs==15.0.1
34 | # via -r requirements.txt
35 | cssselect==1.3.0
36 | # via
37 | # -r requirements.txt
38 | # parsel
39 | dwdparse==0.9.15
40 | # via -r requirements.txt
41 | falcon==4.0.2
42 | # via -r requirements.txt
43 | fastapi==0.115.12
44 | # via -r requirements.txt
45 | freezegun==1.5.1
46 | # via -r requirements-dev.in
47 | gunicorn==23.0.0
48 | # via -r requirements.txt
49 | h11==0.16.0
50 | # via
51 | # -r requirements.txt
52 | # httpcore
53 | # uvicorn
54 | httpcore==1.0.9
55 | # via
56 | # -r requirements.txt
57 | # httpx
58 | httpx==0.28.1
59 | # via -r requirements.txt
60 | huey==2.5.3
61 | # via -r requirements.txt
62 | humanfriendly==10.0
63 | # via
64 | # -r requirements.txt
65 | # coloredlogs
66 | idna==3.10
67 | # via
68 | # -r requirements.txt
69 | # anyio
70 | # httpx
71 | # requests
72 | ijson==3.3.0
73 | # via -r requirements.txt
74 | iniconfig==2.1.0
75 | # via pytest
76 | isal==1.7.2
77 | # via -r requirements.txt
78 | jmespath==1.0.1
79 | # via
80 | # -r requirements.txt
81 | # parsel
82 | lxml==5.4.0
83 | # via
84 | # -r requirements.txt
85 | # parsel
86 | numpy==2.2.5
87 | # via
88 | # -r requirements.txt
89 | # shapely
90 | orjson==3.10.18
91 | # via -r requirements.txt
92 | packaging==25.0
93 | # via
94 | # -r requirements.txt
95 | # gunicorn
96 | # parsel
97 | # pytest
98 | parsel==1.10.0
99 | # via -r requirements.txt
100 | pluggy==1.5.0
101 | # via pytest
102 | psycopg2-binary==2.9.10
103 | # via -r requirements.txt
104 | pydantic==2.11.4
105 | # via
106 | # -r requirements.txt
107 | # fastapi
108 | pydantic-core==2.33.2
109 | # via
110 | # -r requirements.txt
111 | # pydantic
112 | pyproj==3.7.1
113 | # via -r requirements.txt
114 | pytest==8.3.5
115 | # via
116 | # -r requirements-dev.in
117 | # pytest-env
118 | pytest-env==1.1.5
119 | # via -r requirements-dev.in
120 | python-dateutil==2.9.0.post0
121 | # via
122 | # -r requirements.txt
123 | # freezegun
124 | redis==5.2.1
125 | # via -r requirements.txt
126 | requests==2.32.3
127 | # via -r requirements.txt
128 | ruff==0.11.7
129 | # via -r requirements-dev.in
130 | sentry-sdk==2.27.0
131 | # via -r requirements.txt
132 | shapely==2.1.0
133 | # via -r requirements.txt
134 | six==1.17.0
135 | # via
136 | # -r requirements.txt
137 | # python-dateutil
138 | sniffio==1.3.1
139 | # via
140 | # -r requirements.txt
141 | # anyio
142 | starlette==0.46.2
143 | # via
144 | # -r requirements.txt
145 | # fastapi
146 | typing-extensions==4.13.2
147 | # via
148 | # -r requirements.txt
149 | # fastapi
150 | # pydantic
151 | # pydantic-core
152 | # typing-inspection
153 | typing-inspection==0.4.0
154 | # via
155 | # -r requirements.txt
156 | # pydantic
157 | urllib3==2.4.0
158 | # via
159 | # -r requirements.txt
160 | # requests
161 | # sentry-sdk
162 | uv==0.7.1
163 | # via -r requirements-dev.in
164 | uvicorn==0.34.2
165 | # via -r requirements.txt
166 | w3lib==2.3.1
167 | # via
168 | # -r requirements.txt
169 | # parsel
170 | watchfiles==1.0.5
171 | # via -r requirements-dev.in
172 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # This file was autogenerated by uv via the following command:
2 | # uv pip compile -o requirements.txt pyproject.toml
3 | annotated-types==0.7.0
4 | # via pydantic
5 | anyio==4.9.0
6 | # via
7 | # httpx
8 | # starlette
9 | astral==3.2
10 | # via brightsky (pyproject.toml)
11 | asyncpg==0.30.0
12 | # via brightsky (pyproject.toml)
13 | certifi==2025.4.26
14 | # via
15 | # httpcore
16 | # httpx
17 | # pyproj
18 | # requests
19 | # sentry-sdk
20 | charset-normalizer==3.4.1
21 | # via requests
22 | click==8.1.8
23 | # via
24 | # brightsky (pyproject.toml)
25 | # uvicorn
26 | coloredlogs==15.0.1
27 | # via brightsky (pyproject.toml)
28 | cssselect==1.3.0
29 | # via parsel
30 | dwdparse==0.9.15
31 | # via brightsky (pyproject.toml)
32 | falcon==4.0.2
33 | # via brightsky (pyproject.toml)
34 | fastapi==0.115.12
35 | # via brightsky (pyproject.toml)
36 | gunicorn==23.0.0
37 | # via brightsky (pyproject.toml)
38 | h11==0.16.0
39 | # via
40 | # httpcore
41 | # uvicorn
42 | httpcore==1.0.9
43 | # via httpx
44 | httpx==0.28.1
45 | # via brightsky (pyproject.toml)
46 | huey==2.5.3
47 | # via brightsky (pyproject.toml)
48 | humanfriendly==10.0
49 | # via coloredlogs
50 | idna==3.10
51 | # via
52 | # anyio
53 | # httpx
54 | # requests
55 | ijson==3.3.0
56 | # via dwdparse
57 | isal==1.7.2
58 | # via brightsky (pyproject.toml)
59 | jmespath==1.0.1
60 | # via parsel
61 | lxml==5.4.0
62 | # via parsel
63 | numpy==2.2.5
64 | # via
65 | # brightsky (pyproject.toml)
66 | # shapely
67 | orjson==3.10.18
68 | # via brightsky (pyproject.toml)
69 | packaging==25.0
70 | # via
71 | # gunicorn
72 | # parsel
73 | parsel==1.10.0
74 | # via brightsky (pyproject.toml)
75 | psycopg2-binary==2.9.10
76 | # via brightsky (pyproject.toml)
77 | pydantic==2.11.4
78 | # via fastapi
79 | pydantic-core==2.33.2
80 | # via pydantic
81 | pyproj==3.7.1
82 | # via brightsky (pyproject.toml)
83 | python-dateutil==2.9.0.post0
84 | # via brightsky (pyproject.toml)
85 | redis==5.2.1
86 | # via brightsky (pyproject.toml)
87 | requests==2.32.3
88 | # via brightsky (pyproject.toml)
89 | sentry-sdk==2.27.0
90 | # via brightsky (pyproject.toml)
91 | shapely==2.1.0
92 | # via brightsky (pyproject.toml)
93 | six==1.17.0
94 | # via python-dateutil
95 | sniffio==1.3.1
96 | # via anyio
97 | starlette==0.46.2
98 | # via fastapi
99 | typing-extensions==4.13.2
100 | # via
101 | # fastapi
102 | # pydantic
103 | # pydantic-core
104 | # typing-inspection
105 | typing-inspection==0.4.0
106 | # via pydantic
107 | urllib3==2.4.0
108 | # via
109 | # requests
110 | # sentry-sdk
111 | uvicorn==0.34.2
112 | # via brightsky (pyproject.toml)
113 | w3lib==2.3.1
114 | # via parsel
115 |
--------------------------------------------------------------------------------
/scripts/benchmark.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import datetime
4 | import logging
5 | import random
6 | import re
7 | import time
8 | from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait
9 | from contextlib import contextmanager
10 | from functools import lru_cache
11 | from multiprocessing import cpu_count
12 |
13 | import click
14 | import psycopg2
15 | import requests
16 | from dateutil.tz import tzutc
17 | from falcon.testing import TestClient
18 |
19 | from brightsky import db, tasks
20 | from brightsky.settings import settings
21 | from brightsky.utils import configure_logging
22 | from brightsky.web import app
23 |
24 |
25 | logger = logging.getLogger('benchmark')
26 |
27 |
28 | SERVER = 'http://localhost:8000'
29 |
30 |
31 | @contextmanager
32 | def _time(description, precision=0, unit='s'):
33 | start = time.time()
34 | yield
35 | delta = round(time.time() - start, precision)
36 | if unit == 'h':
37 | delta_str = str(datetime.timedelta(seconds=delta))
38 | else:
39 | delta_str = '{:{}.{}f} s'.format(delta, precision+4, precision)
40 | if not description.rstrip().endswith(':'):
41 | description += ':'
42 | click.echo(f'{description} {delta_str}')
43 |
44 |
45 | @lru_cache
46 | def get_client():
47 | return TestClient(app)
48 |
49 |
50 | @click.group()
51 | def cli():
52 | try:
53 | settings['DATABASE_URL'] = settings.BENCHMARK_DATABASE_URL
54 | except AttributeError:
55 | raise click.ClickException(
56 | 'Please set the BRIGHTSKY_BENCHMARK_DATABASE_URL environment '
57 | 'variable')
58 | # This gives us roughly 100 days of weather records in total:
59 | # 89 from recent observations, 1 from current observations, 10 from MOSMIX
60 | settings['MIN_DATE'] = datetime.datetime(2020, 1, 1, tzinfo=tzutc())
61 | settings['MAX_DATE'] = datetime.datetime(2020, 3, 30, tzinfo=tzutc())
62 |
63 |
64 | @cli.command(help='Recreate and populate benchmark database')
65 | def build():
66 | logger.info('Dropping and recreating database')
67 | db_url_base, db_name = settings.DATABASE_URL.rsplit('/', 1)
68 | with psycopg2.connect(db_url_base + '/postgres') as conn:
69 | with conn.cursor() as cur:
70 | conn.set_isolation_level(0)
71 | cur.execute('DROP DATABASE IF EXISTS %s' % (db_name,))
72 | cur.execute('CREATE DATABASE %s' % (db_name,))
73 | db.migrate()
74 | file_infos = tasks.poll()
75 | # Make sure we finish parsing MOSMIX before going ahead as current
76 | # observations depend on it
77 | tasks.parse(url=next(file_infos)['url'], export=True)
78 | with ThreadPoolExecutor(max_workers=2*cpu_count()+1) as executor:
79 | with _time('Database creation time', unit='h'):
80 | futures = [
81 | executor.submit(tasks.parse, url=file_info['url'], export=True)
82 | for file_info in file_infos]
83 | finished, pending = wait(futures, return_when=FIRST_EXCEPTION)
84 | for f in pending:
85 | f.cancel()
86 | for f in finished:
87 | # Re-raise any occured exceptions
88 | if exc := f.exception():
89 | raise exc
90 |
91 |
92 | @cli.command(help='Calculate database size')
93 | def db_size():
94 | m = re.search(r'/(\w+)$', settings.DATABASE_URL)
95 | db_name = m.group(1) if m else 'postgres'
96 | with db.get_connection() as conn:
97 | with conn.cursor() as cur:
98 | cur.execute(
99 | 'SELECT pg_database_size(%s)', (db_name,))
100 | db_size = cur.fetchone()
101 | table_sizes = {}
102 | for table in ['weather', 'synop', 'sources']:
103 | cur.execute('SELECT pg_total_relation_size(%s)', (table,))
104 | table_sizes[table] = cur.fetchone()[0]
105 | click.echo('Total database size:\n%6d MB' % (db_size[0] / 1024 / 1024))
106 | click.echo(
107 | 'Table sizes:\n' + '\n'.join(
108 | '%6d MB %s' % (size / 1024 / 1024, table)
109 | for table, size in table_sizes.items()))
110 |
111 |
112 | @cli.command(help='Re-parse MOSMIX data')
113 | def mosmix_parse():
114 | MOSMIX_URL = (
115 | 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_S/'
116 | 'all_stations/kml/MOSMIX_S_LATEST_240.kmz')
117 | with _time('MOSMIX Re-parse', unit='h'):
118 | tasks.parse(url=MOSMIX_URL, export=True)
119 |
120 |
121 | def _query_sequential(path, kwargs_list, **base_kwargs):
122 | for kwargs in kwargs_list:
123 | requests.get(
124 | f'{SERVER}{path}',
125 | params={**base_kwargs, **kwargs},
126 | )
127 |
128 |
129 | def _query_parallel(path, kwargs_list, **base_kwargs):
130 | with ThreadPoolExecutor(max_workers=2*cpu_count()+1) as executor:
131 | for kwargs in kwargs_list:
132 | executor.submit(
133 | requests.get,
134 | f'{SERVER}{path}',
135 | params={**base_kwargs, **kwargs},
136 | )
137 |
138 |
139 | @cli.command('query', help='Query records from database')
140 | def query_():
141 | with _time('\nTotal', precision=2):
142 | _query()
143 |
144 |
145 | def _query():
146 | # Generate 50 random locations within Germany's bounding box. Locations
147 | # and sources will be the same across different runs since we hard-code the
148 | # PRNG seed.
149 | random.seed(1)
150 | location_kwargs = [
151 | {
152 | 'lat': 47.30 + (i % 30) * (54.98 - 47.30) / 29,
153 | 'lon': 5.99 + (i // 30) * (15.02 - 5.99) / 29,
154 | }
155 | for i in range(900)]
156 | with db.get_connection() as conn:
157 | with conn.cursor() as cur:
158 | cur.execute(
159 | """
160 | SELECT dwd_station_id, id
161 | FROM sources
162 | WHERE observation_type = %s
163 | """,
164 | ('historical',))
165 | rows = random.choices(cur.fetchall(), k=100)
166 | station_kwargs = [
167 | {'dwd_station_id': row['dwd_station_id']} for row in rows]
168 | source_kwargs = [{'source_id': row['id']} for row in rows]
169 | cur.execute(
170 | """
171 | SELECT MAX(last_record)
172 | FROM sources
173 | WHERE observation_type = 'current'
174 | """)
175 | today = cur.fetchone()['max'].date().isoformat()
176 | date = '2022-08-13'
177 | last_date = '2022-08-20'
178 |
179 | def _test_with_kwargs(kwargs_list):
180 | with _time(' 900 one-day queries, sequential', precision=2):
181 | _query_sequential('/weather', kwargs_list, date=date)
182 | with _time(' 900 one-day queries, parallel: ', precision=2):
183 | _query_parallel('/weather', kwargs_list, date=date)
184 | with _time(' 900 one-week queries, sequential', precision=2):
185 | _query_sequential(
186 | '/weather', kwargs_list, date=date, last_date=last_date)
187 | with _time(' 900 one-week queries, parallel: ', precision=2):
188 | _query_parallel(
189 | '/weather', kwargs_list, date=date, last_date=last_date)
190 |
191 | click.echo('Sources by lat/lon:')
192 | with _time(' 900 queries, sequential: ', precision=2):
193 | _query_sequential('/sources', location_kwargs)
194 | with _time(' 900 queries, parallel: ', precision=2):
195 | _query_parallel('/sources', location_kwargs)
196 | click.echo('\nSources by station:')
197 | with _time(' 900 queries, sequential: ', precision=2):
198 | _query_sequential('/sources', station_kwargs)
199 | with _time(' 900 queries, parallel: ', precision=2):
200 | _query_parallel('/sources', station_kwargs)
201 | click.echo('\nSources by source:')
202 | with _time(' 900 queries, sequential: ', precision=2):
203 | _query_sequential('/sources', source_kwargs)
204 | with _time(' 900 queries, parallel: ', precision=2):
205 | _query_parallel('/sources', source_kwargs)
206 |
207 | click.echo('\nWeather by lat/lon:')
208 | _test_with_kwargs(location_kwargs)
209 | click.echo('\nWeather by lat/lon, today:')
210 | with _time(' 900 one-day queries, sequential', precision=2):
211 | _query_sequential('/weather', location_kwargs, date=today)
212 | with _time(' 900 one-day queries, parallel: ', precision=2):
213 | _query_parallel('/weather', location_kwargs, date=today)
214 | click.echo('\nWeather by station:')
215 | _test_with_kwargs(station_kwargs)
216 | click.echo('\nWeather by source:')
217 | _test_with_kwargs(source_kwargs)
218 |
219 | click.echo('\nCurrent weather by lat/lon:')
220 | with _time(' 900 queries, sequential: ', precision=2):
221 | _query_sequential('/current_weather', location_kwargs)
222 | with _time(' 900 queries, parallel: ', precision=2):
223 | _query_parallel('/current_weather', location_kwargs)
224 | click.echo('\nCurrent weather by station:')
225 | with _time(' 900 queries, sequential: ', precision=2):
226 | _query_sequential('/current_weather', station_kwargs)
227 | with _time(' 900 queries, parallel: ', precision=2):
228 | _query_parallel('/current_weather', station_kwargs)
229 |
230 |
231 | if __name__ == '__main__':
232 | configure_logging()
233 | cli()
234 |
--------------------------------------------------------------------------------
/scripts/benchmark_compression.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import timeit
3 |
4 |
5 | URL = 'http://localhost:8000/radar'
6 | BBOXES = [
7 | None,
8 | '200,200,800,800',
9 | '400,400,600,600',
10 | ]
11 |
12 |
13 | def p(s, back=0):
14 | sys.stdout.write('\b' * back)
15 | sys.stdout.write(s)
16 | sys.stdout.flush()
17 |
18 |
19 | def benchmark():
20 | for fmt in ['compressed', 'bytes', 'plain']:
21 | p(f'{fmt:10s} ')
22 | for bbox in BBOXES:
23 | time = timeit.timeit(
24 | 'requests.get(url, params=params).raise_for_status()',
25 | setup='import requests',
26 | number=20,
27 | globals={
28 | 'url': URL,
29 | 'params': {
30 | 'format': fmt,
31 | 'bbox': bbox,
32 | },
33 | },
34 | )
35 | time = int(round(time / 20 * 1000))
36 | p(f'{time:4d} ', 3)
37 | print('')
38 |
39 |
40 | if __name__ == '__main__':
41 | benchmark()
42 |
--------------------------------------------------------------------------------
/scripts/benchmark_radar.py:
--------------------------------------------------------------------------------
1 | import array
2 | import os
3 | import time
4 | import zlib
5 | from contextlib import contextmanager
6 |
7 | import psycopg2
8 |
9 | from brightsky.db import fetch, get_connection
10 | from brightsky.utils import configure_logging, load_dotenv
11 | from brightsky.settings import settings
12 | from brightsky.tasks import parse
13 |
14 |
15 | @contextmanager
16 | def _time(description):
17 | start = time.time()
18 | yield
19 | delta = int(round((time.time() - start) / 50, 3) * 1000)
20 | description += ':'
21 | print(f'{description:15s} {delta:5d} ms')
22 |
23 |
24 | def setup():
25 | with get_connection() as conn:
26 | with conn.cursor() as cur:
27 | cur.execute('drop table if exists radar;')
28 | cur.execute(
29 | """
30 | CREATE TABLE radar (
31 | timestamp timestamptz NOT NULL,
32 |
33 | source varchar(255) NOT NULL,
34 | precipitation_5 smallint[1200][1100] CHECK (
35 | array_dims(precipitation_5) = '[1:1200][1:1100]' AND
36 | 0 <= ALL(precipitation_5)
37 | ),
38 | precipitation_5_raw bytea NOT NULL,
39 |
40 | CONSTRAINT radar_key UNIQUE (timestamp)
41 | );
42 | """,
43 | )
44 | conn.commit()
45 |
46 |
47 | def reset():
48 | with get_connection() as conn:
49 | with conn.cursor() as cur:
50 | cur.execute('delete from radar')
51 | conn.commit()
52 | _vacuum()
53 | parse('https://opendata.dwd.de/weather/radar/composite/rv/DE1200_RV2305021315.tar.bz2') # noqa
54 | parse('https://opendata.dwd.de/weather/radar/composite/rv/DE1200_RV2305030700.tar.bz2') # noqa
55 |
56 |
57 | def _vacuum():
58 | conn = psycopg2.connect(settings.DATABASE_URL)
59 | conn.autocommit = True
60 | cur = conn.cursor()
61 | cur.execute('VACUUM ANALYZE radar')
62 | conn.commit()
63 | conn.close()
64 |
65 |
66 | def print_size():
67 | size = fetch(
68 | "select pg_size_pretty(pg_total_relation_size('radar'));"
69 | )[0][0]
70 | print("DB size: ", size)
71 |
72 |
73 | def time_full_array():
74 | with _time('Array full'):
75 | fetch('select timestamp, source, precipitation_5 from radar')
76 |
77 |
78 | def time_clip_array():
79 | with _time('Array clipped'):
80 | fetch(
81 | """
82 | select timestamp, source, precipitation_5[400:600][400:600]
83 | from radar
84 | """
85 | )
86 |
87 |
88 | def _make_array(buf):
89 | a = array.array('H')
90 | a.frombytes(buf)
91 | return a
92 |
93 |
94 | def time_full_bytes():
95 | with _time('Bytes full'):
96 | rows = fetch(
97 | 'select timestamp, source, precipitation_5_raw from radar',
98 | )
99 | for row in rows:
100 | data = _make_array(zlib.decompress(row['precipitation_5_raw']))
101 | # data = [
102 | # x if x < 4096 else None
103 | # for x in data
104 | # ]
105 | precip_5 = [ # noqa
106 | data[i*1100:(i+1)*1100].tolist()
107 | for i in reversed(range(1200))
108 | ]
109 |
110 |
111 | def time_clip_bytes():
112 | with _time('Bytes clipped'):
113 | rows = fetch(
114 | 'select timestamp, source, precipitation_5_raw from radar',
115 | )
116 | for row in rows:
117 | # data = array.array('H')
118 | raw = zlib.decompress(row['precipitation_5_raw'])
119 | precip_5 = [ # noqa
120 | _make_array(raw[i*2200+800:i*2200+1200]).tolist()
121 | for i in range(400, 600)
122 | ]
123 | # data = [
124 | # x if x < 4096 else None
125 | # for x in data
126 | # ]
127 | # precip_5 = [
128 | # data[i*1100+400:i*1100+600]
129 | # for i in range(400, 600)
130 | # ]
131 |
132 |
133 | def main():
134 | setup()
135 | for level in range(10):
136 | print('')
137 | os.environ['ZLIB_COMPRESSION_LEVEL'] = str(level)
138 | reset()
139 | print('\nCOMPRESSION LEVEL', level)
140 | print_size()
141 | time_full_array()
142 | time_clip_array()
143 | time_full_bytes()
144 | time_clip_bytes()
145 |
146 |
147 | if __name__ == '__main__':
148 | configure_logging()
149 | load_dotenv()
150 | main()
151 |
--------------------------------------------------------------------------------
/scripts/radar_coordinates.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import json
4 | import os.path
5 | import re
6 | import subprocess
7 | import tempfile
8 |
9 | proj_str = '+proj=stere +lat_0=90 +lat_ts=60 +lon_0=10 +a=6378137 +b=6356752.3142451802 +no_defs +x_0=543196.83521776402 +y_0=3622588.8619310018 -f "%.10g"' # noqa
10 |
11 | HEIGHT = 1200
12 | WIDTH = 1100
13 |
14 |
15 | def main():
16 | with tempfile.TemporaryDirectory() as tmpdir:
17 | in_path = os.path.join(tmpdir, 'in')
18 | with open(in_path, 'w') as f:
19 | for y in range(HEIGHT):
20 | for x in range(WIDTH):
21 | f.write(f'{x*1000} {-y*1000}\n')
22 | p = subprocess.run(
23 | f'invproj {proj_str} {in_path}',
24 | shell=True,
25 | cwd=tmpdir,
26 | capture_output=True,
27 | text=True,
28 | )
29 | coords = []
30 | for line in p.stdout.splitlines():
31 | xy = [float(x) for x in re.split(r'\s+', line.strip())]
32 | assert len(xy) == 2
33 | coords.append(xy)
34 | coords = [
35 | coords[row*WIDTH:(row+1)*WIDTH]
36 | for row in range(HEIGHT)
37 | ]
38 | with open('radar_coordinates.json', 'w') as f:
39 | # It'd be nicer if we could keep the proj output as strings and
40 | # convince json to output strings without quotes (so they will be
41 | # read as float), but that's not easily available through custom
42 | # encoders...
43 | json.dump(coords, f)
44 |
45 |
46 | if __name__ == '__main__':
47 | main()
48 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | from dataclasses import dataclass
4 | from pathlib import Path
5 | from typing import Any
6 | from urllib.parse import urlparse
7 |
8 | import psycopg2
9 | import pytest
10 | from fastapi.testclient import TestClient
11 | from psycopg2.extras import execute_values
12 |
13 | from brightsky.db import get_connection, migrate
14 |
15 |
16 | @pytest.fixture(scope='session')
17 | def data_dir():
18 | return Path(os.path.dirname(__file__)) / 'data'
19 |
20 |
21 | @pytest.fixture(scope='session')
22 | def _database():
23 | if not os.getenv('BRIGHTSKY_DATABASE_URL'):
24 | pytest.skip('See README for running database-based tests.')
25 | url = urlparse(os.getenv('BRIGHTSKY_DATABASE_URL'))
26 | postgres_url = f'postgres://{url.netloc}'
27 | db_name = url.path.lstrip('/')
28 | assert db_name
29 | conn = psycopg2.connect(postgres_url)
30 | conn.autocommit = True
31 | with conn.cursor() as cur:
32 | cur.execute(f'DROP DATABASE IF EXISTS {db_name}')
33 | cur.execute(f'CREATE DATABASE {db_name}')
34 | conn.close()
35 | migrate()
36 | yield
37 | if hasattr(get_connection, '_pool'):
38 | get_connection._pool.closeall()
39 | conn = psycopg2.connect(postgres_url)
40 | conn.autocommit = True
41 | conn.autocommit = True
42 | with conn.cursor() as cur:
43 | cur.execute(f'DROP DATABASE {db_name}')
44 | conn.close()
45 |
46 |
47 | @dataclass
48 | class TestConnection:
49 | """Wrapper for database connection with some rough convenience functions"""
50 |
51 | conn: Any
52 |
53 | def insert(self, table, rows):
54 | with self.cursor() as cur:
55 | fields = tuple(rows[0])
56 | field_placeholders = [f'%({field})s' for field in fields]
57 | execute_values(
58 | cur,
59 | f"INSERT INTO {table} ({', '.join(fields)}) VALUES %s",
60 | rows,
61 | template=f"({', '.join(field_placeholders)})")
62 | self.conn.commit()
63 |
64 | def fetch(self, sql):
65 | with self.conn.cursor() as cur:
66 | cur.execute(sql)
67 | rows = cur.fetchall()
68 | self.conn.commit()
69 | return rows
70 |
71 | def table(self, name):
72 | return self.fetch(f'SELECT * FROM {name}')
73 |
74 | def __getattr__(self, name):
75 | return getattr(self.conn, name)
76 |
77 |
78 | @pytest.fixture
79 | def db(_database):
80 | with get_connection() as conn:
81 | yield TestConnection(conn)
82 | with conn.cursor() as cur:
83 | cur.execute("""
84 | DELETE FROM parsed_files;
85 | DELETE FROM synop;
86 | DELETE FROM weather;
87 | DELETE FROM sources;
88 | REFRESH MATERIALIZED VIEW current_weather;
89 | """)
90 |
91 |
92 | @pytest.fixture
93 | def api(db):
94 | from brightsky.web import app
95 | with TestClient(app) as client:
96 | yield client
97 |
98 |
99 | def pytest_configure(config):
100 | # Dirty mock so we don't download the station list on every test run
101 | from dwdparse.stations import _converter
102 | # Must contain all stations that we use in test data
103 | _converter.dwd_to_wmo = {
104 | 'XXX': '01028',
105 | 'YYY': '01049',
106 | '01766': '10315',
107 | '04911': '10788',
108 | '05484': 'M031',
109 | }
110 | _converter.wmo_to_dwd = dict(
111 | reversed(x) for x in _converter.dwd_to_wmo.items())
112 | _converter.last_update = time.time()
113 |
--------------------------------------------------------------------------------
/tests/data/10minutenwerte_SOLAR_01766_akt.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/data/10minutenwerte_SOLAR_01766_akt.zip
--------------------------------------------------------------------------------
/tests/data/DE1200_RV2305081330.tar.bz2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/data/DE1200_RV2305081330.tar.bz2
--------------------------------------------------------------------------------
/tests/data/Meta_Daten_zehn_min_sd_01766.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/data/Meta_Daten_zehn_min_sd_01766.zip
--------------------------------------------------------------------------------
/tests/data/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_MUL.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/data/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_MUL.zip
--------------------------------------------------------------------------------
/tests/data/dwd_opendata_index.html:
--------------------------------------------------------------------------------
1 |
2 | Index of /climate_environment/CDC/observations_germany/climate/hourly/wind/recent/
3 |
4 | Index of /climate_environment/CDC/observations_germany/climate/hourly/wind/recent/
../
5 | BESCHREIBUNG_obsgermany_climate_hourly_wind_rec..> 11-Jan-2020 12:38 68097
6 | DESCRIPTION_obsgermany_climate_hourly_wind_rece..> 11-Jan-2020 12:38 67800
7 | FF_Stundenwerte_Beschreibung_Stationen.txt 29-Mar-2020 08:55 104645
8 | stundenwerte_FF_00011_akt.zip 29-Mar-2020 08:55 70523
9 | stundenwerte_FF_00090_akt.zip 29-Mar-2020 08:56 71408
10 | stundenwerte_P0_00096_akt.zip 29-Mar-2020 08:57 47355
11 | stundenwerte_RR_00102_akt.zip 29-Mar-2020 08:58 74372
12 | stundenwerte_SD_00125_akt.zip 29-Mar-2020 08:59 69633
13 | stundenwerte_TD_00161_akt.zip 29-Mar-2020 09:02 70167
14 | stundenwerte_TU_00161_akt.zip 29-Mar-2020 09:00 70165
15 | stundenwerte_VV_00161_akt.zip 29-Mar-2020 09:03 70168
16 | stundenwerte_N_00161_akt.zip 29-Mar-2020 09:01 70166
17 | MOSMIX_L_LATEST.kmz 29-Mar-2020 09:57 91636300
18 | MOSMIX_S_LATEST_240.kmz 29-Mar-2020 10:21 38067304
19 | K611_-BEOB.csv 06-Apr-2020 10:38 7343
20 | 10minutenwerte_extrema_wind_01766_now.zip 08-Jun-2020 09:12 701
21 | 10minutenwerte_extrema_wind_01766_akt.zip 08-Jun-2020 04:27 519692
22 | 10minutenwerte_extrema_wind_01766_20100101_2019..> 09-Apr-2020 09:16 3661729
23 | 10minutenwerte_SOLAR_01766_akt.zip 12-Apr-2023 00:50 367557
24 |
25 |
26 |
--------------------------------------------------------------------------------
/tests/data/observations_current.csv:
--------------------------------------------------------------------------------
1 | surface observations;Parameter description;cloud_cover_total;daily_mean_of_temperature_previous_day;depth_of_new_snow;dew_point_temperature_at_2_meter_above_ground;diffuse_solar_radiation_last_hour;direct_solar_radiation_last_24_hours;direct_solar_radiation_last_hour;dry_bulb_temperature_at_2_meter_above_ground;evaporation/evapotranspiration_last_24_hours;global_radiation_last_hour;global_radiation_past_24_hours;height_of_base_of_lowest_cloud_above_station;horizontal_visibility;maximum_of_10_minutes_mean_of_wind_speed_for_previous_day;maximum_of_temperature_for_previous_day;maximum_temperature_last_12_hours_2_meters_above_ground;maximum_wind_speed_as_10_minutes_mean_during_last_hour;maximum_wind_speed_during_last_6_hours;maximum_wind_speed_for_previous_day;maximum_wind_speed_last_hour;mean_wind_direction_during_last_10 min_at_10_meters_above_ground;mean_wind_speed_during last_10_min_at_10_meters_above_ground;minimum_of_temperature_at_5_cm_above_ground_for_previous_day;minimum_of_temperature_for_previous_day;minimum_temperature_last_12_hours_2_meters_above_ground;minimum_temperature_last_12_hours_5_cm_above_ground;past_weather_1;past_weather_2;precipitation_amount_last_24_hours;precipitation_amount_last_3_hours;precipitation_amount_last_6_hours;precipitation_amount_last_hour;precipitation_last_12_hours;present_weather;pressure_reduced_to_mean_sea_level;relative_humidity;sea/water_temperature;temperature_at_5_cm_above_ground;total_snow_depth;total_time_of_sunshine_during_last_hour;total_time_of_sunshine_past_day
2 | 01049;Unit;%;Grad C;cm;Grad C;W/m2;W/m2;W/m2;Grad C;mm;W/m2;W/m2;m;km;km/h;Grad C;Grad C;km/h;km/h;km/h;km/h;Grad;km/h;Grad C;Grad C;Grad C;Grad C;CODE_TABLE;CODE_TABLE;mm;mm;mm;mm;mm;CODE_TABLE;hPa;%;Grad C;Grad C;cm;min;h
3 | Datum;Uhrzeit (UTC);Wolkenbedeckung;mittlere Temperatur (vergangener Tag, 2m);Neuschneehoehe;Taupunkttemperatur (2m);Diffuse Strahlung (letzte Stunde);Direkte Strahlung (vergangene 24 Stunden);Direkte Strahlung (letzte Stunde);Temperatur (2m);Evaporation (vergangene 24 Stunden);Globalstrahlung (letzte Stunde);Globalstrahlung (vergangene 24 Stunden);Wolkenuntergrenze;Sichtweite;Maximalwind (vergangener Tag);Maximumtemperatur (vergangener Tag, 2m);Maximumtemperatur (letzte 12 Stunden, 2m);Maximalwind (letzte Stunde);Windboen (letzte 6 Stunden);Windboen (vergangener Tag);Windboen (letzte Stunde);Windrichtung;Windgeschwindigkeit;Minimumtemperatur (vergangener Tag, 5cm);Minimumtemperatur (vergangener Tag, 2m);Minimumtemperatur (letzte 12 Stunden, 2m);Minimumtemperatur (letzte 12 Stunden, 5cm);vergangenes Wetter 1;vergangenes Wetter 2;Niederschlag (letzte 24 Stunden);Niederschlag (letzte 3 Stunden);Niederschlag (letzte 6 Stunden);Niederschlag (letzte Stunde);Niederschlag (letzte 12 Stunden);aktuelles Wetter;Druck (auf Meereshoehe);Relative Feuchte;Wassertemperatur;Temperatur (5cm);Schneehoehe;Sonnenscheindauer (letzte Stunde);Sonnenscheindauer (vergangener Tag)
4 | 06.04.20;08:00;---;---;---;-10;---;---;---;-3,1;---;---;---;---;---;---;---;---;16;---;---;21;140;14;---;---;---;---;---;---;---;---;---;0;---;---;1023,1;59;---;---;---;---;---
5 | 06.04.20;07:00;---;---;---;-10,2;---;---;---;-4,5;---;---;---;---;---;---;---;---;17;---;---;22;140;14;---;---;---;---;---;---;---;---;---;0;---;---;1023,6;64;---;---;---;---;---
6 | 06.04.20;06:00;---;---;---;-10,4;---;---;---;-5,8;---;---;---;---;---;15;---;-2,1;---;18;---;19;150;14;---;---;-11,4;---;---;---;0,7;---;---;0;0,1;---;1023,9;70;---;---;---;---;---
7 | 06.04.20;05:00;---;---;---;-9,6;---;---;---;-6,8;---;---;---;---;---;---;---;---;7;---;---;12;120;7;---;---;---;---;---;---;---;---;---;0;---;---;1024;80;---;---;---;---;---
8 | 06.04.20;04:00;---;---;---;-11,3;---;---;---;-9,6;---;---;---;---;---;---;---;---;6;---;---;10;130;7;---;---;---;---;---;---;---;---;---;0;---;---;1024,1;87;---;---;---;---;---
9 | 06.04.20;03:00;---;---;---;-11,6;---;---;---;-10,3;---;---;---;---;---;---;---;---;---;---;---;13;180;4;---;---;---;---;---;---;---;---;---;0;---;---;1024;90;---;---;---;---;---
10 | 06.04.20;02:00;---;---;---;-10,5;---;---;---;-9,3;---;---;---;---;---;---;---;---;11;---;---;14;170;7;---;---;---;---;---;---;---;---;---;0;---;---;1023,4;91;---;---;---;---;---
11 | 06.04.20;01:00;---;---;---;-10,8;---;---;---;-9,1;---;---;---;---;---;---;---;---;13;---;---;15;180;4;---;---;---;---;---;---;---;---;---;0;---;---;1023,2;87;---;---;---;---;---
12 | 06.04.20;00:00;---;---;---;-10,9;---;---;---;-9,3;---;---;---;---;---;16;---;---;---;22;---;15;110;11;---;---;---;---;---;---;---;---;0,1;0;---;---;1023,1;88;---;---;---;---;---
13 | 05.04.20;23:00;---;---;---;-9,6;---;---;---;-8,1;---;---;---;---;---;---;---;---;13;---;---;15;70;7;---;---;---;---;---;---;---;---;---;0;---;---;1022,6;89;---;---;---;---;---
14 | 05.04.20;22:00;---;---;---;-8,7;---;---;---;-7,6;---;---;---;---;---;---;---;---;14;---;---;17;140;11;---;---;---;---;---;---;---;---;---;0;---;---;1022;92;---;---;---;---;---
15 | 05.04.20;21:00;---;---;---;-8,1;---;---;---;-6,4;---;---;---;---;---;---;---;---;---;---;---;17;130;14;---;---;---;---;---;---;---;---;---;0;---;---;1021,6;88;---;---;---;---;---
16 | 05.04.20;20:00;---;---;---;-5,3;---;---;---;-3,8;---;---;---;---;---;---;---;---;10;---;---;14;170;7;---;---;---;---;---;---;---;---;---;0,1;---;---;1020,8;89;---;---;---;---;---
17 | 05.04.20;19:00;---;---;---;-4,3;---;---;---;-2,7;---;---;---;---;---;---;---;---;16;---;---;20;230;11;---;---;---;---;---;---;---;---;---;0;---;---;1020,1;89;---;---;---;---;---
18 | 05.04.20;18:00;---;---;---;-3,5;---;---;---;-2,1;---;---;---;---;---;26;---;0,8;---;36;---;27;220;18;---;---;-9,2;---;---;---;---;---;---;---;0,6;---;1019,6;90;---;---;---;---;---
19 | 05.04.20;17:00;---;---;---;-3,1;---;---;---;-2,2;---;---;---;---;---;---;---;---;19;---;---;26;230;14;---;---;---;---;---;---;---;---;---;0,6;---;---;1019,1;94;---;---;---;---;---
20 | 05.04.20;16:00;---;---;---;-4,3;---;---;---;-1,7;---;---;---;---;---;---;---;---;26;---;---;35;250;18;---;---;---;---;---;---;---;---;---;0;---;---;1019;82;---;---;---;---;---
21 | 05.04.20;15:00;---;---;---;-6,1;---;---;---;-1;---;---;---;---;---;---;---;---;---;---;---;33;270;22;---;---;---;---;---;---;---;---;---;0;---;---;1018,3;68;---;---;---;---;---
22 | 05.04.20;14:00;---;---;---;-7,1;---;---;---;0;---;---;---;---;---;---;---;---;17;---;---;25;240;11;---;---;---;---;---;---;---;---;---;0;---;---;1017,9;59;---;---;---;---;---
23 | 05.04.20;13:00;---;---;---;-9,4;---;---;---;-0,8;---;---;---;---;---;---;---;---;13;---;---;20;190;11;---;---;---;---;---;---;---;---;---;0;---;---;1017,5;52;---;---;---;---;---
24 | 05.04.20;12:00;---;---;---;-10,7;---;---;---;-1,2;---;---;---;---;---;23;---;---;---;---;---;29;168;12;---;---;---;---;---;---;---;---;0;0;---;---;1017,2;48;---;---;---;---;---
25 | 05.04.20;11:00;---;---;---;-12,3;---;---;---;-0,8;---;---;---;---;---;---;---;---;21;---;---;28;210;18;---;---;---;---;---;---;---;---;---;0;---;---;1017;41;---;---;---;---;---
26 | 05.04.20;10:00;---;---;---;-10,8;---;---;---;-0,7;---;---;---;---;---;---;---;---;16;---;---;21;210;14;---;---;---;---;---;---;---;---;---;0;---;---;1016,6;46;---;---;---;---;---
27 | 05.04.20;09:00;---;---;---;-6,8;---;---;---;-0,2;---;---;---;---;---;---;---;---;---;---;---;11;80;4;---;---;---;---;---;---;---;---;---;0;---;---;1016,1;61;---;---;---;---;---
28 | 05.04.20;08:00;---;---;---;-7,4;---;---;---;-3,2;---;---;---;---;---;---;---;---;8;---;---;11;70;7;---;---;---;---;---;---;---;---;---;0;---;---;1015,5;73;---;---;---;---;---
29 |
--------------------------------------------------------------------------------
/tests/data/observations_recent_FF_akt.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jdemaeyer/brightsky/79b17f026dfdbc29309261d4970c76c8bef06b5d/tests/data/observations_recent_FF_akt.zip
--------------------------------------------------------------------------------
/tests/data/station_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Stationslexikon -- Klimadaten Deutschland --
5 |
6 | Stationslexikon |
7 |
8 | Stationsname |
9 | Stations_ID |
10 | Kennung |
11 | Stations- kennung |
12 | Breite |
13 | Länge |
14 | Stations- höhe |
15 | Flussgebiet |
16 | Bundesland |
17 | Beginn |
18 | Ende |
19 |
20 |
21 | Aachen | 3 | SO | 02205 | 50.7827 | 6.0941 | 202 | 803100 | NW | 01.01.1951 | 31.03.2011 |
22 | Aachen | 3 | MN | 10501 | 50.7827 | 6.0941 | 202 | 803100 | NW | 01.04.1950 | 01.04.2011 |
23 | Aachen | 3 | TU | 02205 | 50.7827 | 6.0941 | 202 | 803100 | NW | 01.04.1950 | 31.03.2011 |
24 | Ahaus | 7374 | EB | 01600 | 52.0814 | 6.941 | 46 | 690250 | NW | 01.03.2006 | 08.06.2020 |
25 | Göttingen | 1691 | SY | 10444 | 51.5002 | 9.9507 | 167 | 544280 | NI | 01.10.1950 | 09.06.2020 |
26 | Münster/Osnabrück | 1766 | SO | 01151 | 52.1344 | 7.6969 | 48 | 603180 | NW | 01.10.1989 | 08.06.2020 |
27 | Münster/Osnabrück | 1766 | SY | 10315 | 52.1344 | 7.6969 | 48 | 603180 | NW | 01.10.1989 | 09.06.2020 |
28 | Zehdenick | 5745 | SY | 10283 | 52.966 | 13.327 | 51 | 461050 | BB | 01.06.2004 | 09.06.2020 |
29 | Zehdenick | 5745 | SY | F263 | 52.966 | 13.327 | 51 | 461050 | BB | 01.06.2004 | 09.06.2020 |
30 | Zehdenick | 5745 | TU | 03326 | 52.966 | 13.327 | 51 | 461050 | BB | 01.01.1981 | 08.06.2020 |
31 | Zehren | 5746 | RR | 41267 | 51.199 | 13.405 | 108 | 412690 | SN | 01.01.1981 | 30.04.2020 |
32 |
33 |
34 |
35 | generiert: 10.06.2020 -- Deutscher Wetterdienst --
36 |
37 |
38 |
--------------------------------------------------------------------------------
/tests/test_db.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def test_migrate(db):
5 | assert len(db.table('migrations')) == len(os.listdir('migrations'))
6 |
--------------------------------------------------------------------------------
/tests/test_export.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from dateutil.tz import tzutc
3 |
4 | import pytest
5 |
6 | from brightsky.export import DBExporter, SYNOPExporter
7 |
8 |
9 | SOURCES = [
10 | {
11 | 'observation_type': 'historical',
12 | 'lat': 10.1,
13 | 'lon': 20.2,
14 | 'height': 30.3,
15 | 'wmo_station_id': '10001',
16 | 'dwd_station_id': 'XYZ',
17 | 'station_name': 'Münster',
18 | },
19 | {
20 | 'observation_type': 'historical',
21 | 'lat': 40.4,
22 | 'lon': 50.5,
23 | 'height': 60.6,
24 | 'wmo_station_id': '10002',
25 | 'dwd_station_id': None,
26 | 'station_name': 'Aurich',
27 | },
28 | {
29 | 'observation_type': 'historical',
30 | 'lat': 60.6,
31 | 'lon': 70.7,
32 | 'height': 80.8,
33 | 'wmo_station_id': '10003',
34 | 'dwd_station_id': None,
35 | 'station_name': 'Göttingen',
36 | },
37 | ]
38 | RECORDS = [
39 | {
40 | 'timestamp': datetime.datetime(2020, 8, 18, 18, tzinfo=tzutc()),
41 | 'temperature': 291.25,
42 | 'precipitation': 0.3,
43 | },
44 | {
45 | 'timestamp': datetime.datetime(2020, 8, 18, 19, tzinfo=tzutc()),
46 | 'temperature': 290.25,
47 | 'precipitation': 0.2,
48 | },
49 | {
50 | 'timestamp': datetime.datetime(2020, 8, 18, 20, tzinfo=tzutc()),
51 | 'temperature': 289.25,
52 | 'precipitation': 0.1,
53 | },
54 | ]
55 | FINGERPRINT = {
56 | 'url': 'https://example.com/source.zip',
57 | 'last_modified': datetime.datetime(2020, 8, 19, 12, 34, tzinfo=tzutc()),
58 | 'file_size': 12345
59 | }
60 |
61 |
62 | @pytest.fixture
63 | def exporter():
64 | exporter = DBExporter()
65 | exporter.export(
66 | [
67 | {**SOURCES[0], **RECORDS[0]},
68 | {**SOURCES[1], **RECORDS[1]},
69 | ],
70 | fingerprint=FINGERPRINT)
71 | return exporter
72 |
73 |
74 | def _query_sources(db):
75 | return db.fetch("SELECT * FROM sources ORDER BY id")
76 |
77 |
78 | def _query_records(db, table='weather'):
79 | return db.fetch(
80 | f"""
81 | SELECT * FROM {table}
82 | JOIN sources ON {table}.source_id = sources.id
83 | ORDER BY sources.id, timestamp
84 | """)
85 |
86 |
87 | def test_db_exporter_creates_new_sources(db, exporter):
88 | db_sources = _query_sources(db)
89 | assert len(db_sources) == 2
90 | for source, row in zip(SOURCES[:2], db_sources):
91 | for k, v in source.items():
92 | assert row[k] == v
93 |
94 |
95 | def test_db_exporter_reuses_existing_sources(db, exporter):
96 | exporter.export([{**SOURCES[0], **RECORDS[2]}])
97 | db_sources = _query_sources(db)
98 | assert len(db_sources) == len(SOURCES[:2])
99 | # Exports with only known sources should also not increase the sources_id
100 | # sequence
101 | exporter.export([{**SOURCES[2], **RECORDS[2]}])
102 | db_sources = _query_sources(db)
103 | assert db_sources[2]['id'] == db_sources[0]['id'] + 2
104 |
105 |
106 | def test_db_exporter_creates_new_records(db, exporter):
107 | db_records = _query_records(db)
108 | for record, source, row in zip(RECORDS[:2], SOURCES[:2], db_records):
109 | for k, v in source.items():
110 | assert row[k] == v
111 | for k, v in record.items():
112 | assert row[k] == v
113 |
114 |
115 | def test_db_exporter_updates_existing_records(db, exporter):
116 | record = RECORDS[0].copy()
117 | record['precipitation'] = 10.
118 | record['cloud_cover'] = 50
119 | exporter.export([{**SOURCES[0], **record}])
120 | db_records = _query_records(db)
121 | for k, v in record.items():
122 | assert db_records[0][k] == v
123 |
124 |
125 | def test_db_exporter_updates_parsed_files(db, exporter):
126 | parsed_files = db.fetch("SELECT * FROM parsed_files")
127 | assert len(parsed_files) == 1
128 | for k, v in FINGERPRINT.items():
129 | assert parsed_files[0][k] == v
130 |
131 |
132 | def test_db_exporter_updates_source_first_last_record(db, exporter):
133 | db_sources = _query_sources(db)
134 | assert db_sources[0]['first_record'] == RECORDS[0]['timestamp']
135 | assert db_sources[0]['last_record'] == RECORDS[0]['timestamp']
136 | exporter.export([{**SOURCES[0], **RECORDS[2]}])
137 | db_sources = _query_sources(db)
138 | assert db_sources[0]['first_record'] == RECORDS[0]['timestamp']
139 | assert db_sources[0]['last_record'] == RECORDS[2]['timestamp']
140 |
141 |
142 | def test_synop_exporter(db):
143 | exporter = SYNOPExporter()
144 | assert len(_query_records(db, table='current_weather')) == 0
145 | # Exporter needs to merge separate records for the same source and time
146 | record = RECORDS[0].copy()
147 | record['timestamp'] = datetime.datetime.now(datetime.UTC).replace(
148 | minute=0, second=0, microsecond=0, tzinfo=tzutc())
149 | extra_record = {
150 | 'timestamp': record['timestamp'],
151 | 'pressure_msl': 101010,
152 | }
153 | previous_record = RECORDS[1].copy()
154 | previous_record['timestamp'] = (
155 | record['timestamp'] - datetime.timedelta(minutes=30))
156 | exporter.export([
157 | {**SOURCES[0], **record},
158 | {**SOURCES[0], **extra_record},
159 | {**SOURCES[0], **previous_record},
160 | ])
161 | # Merges records for the same source and timestamp
162 | synop_records = _query_records(db, table='synop')
163 | assert len(synop_records) == 2
164 | assert synop_records[-1]['timestamp'] == record['timestamp']
165 | assert synop_records[-1]['temperature'] == record['temperature']
166 | assert synop_records[-1]['pressure_msl'] == extra_record['pressure_msl']
167 | # Updates current_weather
168 | # XXX: This test may be flaky as the concurrent refresh may not have
169 | # finished yet. Can we somehow wait until the lock is released?
170 | current_weather_records = _query_records(db, table='current_weather')
171 | assert len(current_weather_records) == 1
172 |
--------------------------------------------------------------------------------
/tests/test_parsers.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | import numpy as np
4 | from dateutil.tz import tzutc
5 | from freezegun import freeze_time
6 | from isal import isal_zlib as zlib
7 |
8 | from brightsky.parsers import (
9 | CloudCoverObservationsParser,
10 | CurrentObservationsParser,
11 | DewPointObservationsParser,
12 | get_parser,
13 | MOSMIXParser,
14 | PrecipitationObservationsParser,
15 | PressureObservationsParser,
16 | RADOLANParser,
17 | SolarRadiationObservationsParser,
18 | SunshineObservationsParser,
19 | SYNOPParser,
20 | TemperatureObservationsParser,
21 | VisibilityObservationsParser,
22 | WindGustsObservationsParser,
23 | WindObservationsParser,
24 | )
25 |
26 | from .utils import settings
27 |
28 |
29 | def test_current_observation_parser_loads_station_data_from_db(db, data_dir):
30 | source = {
31 | 'observation_type': 'forecast',
32 | 'lat': 52.12,
33 | 'lon': 7.62,
34 | 'height': 5.,
35 | 'wmo_station_id': '01049',
36 | 'station_name': 'Münster',
37 | }
38 | db.insert('sources', [source])
39 | p = CurrentObservationsParser()
40 | record = next(p.parse(data_dir / 'observations_current.csv'))
41 | for field in ('lat', 'lon', 'height', 'station_name'):
42 | assert record[field] == source[field]
43 |
44 |
45 | def test_observations_parser_skips_file_if_out_of_range(data_dir):
46 | p = PressureObservationsParser()
47 | path = data_dir / 'observations_19950901_20150817_hist.zip'
48 | assert not p.skip_path(path)
49 | with settings(
50 | MIN_DATE=datetime.datetime(2016, 1, 1, tzinfo=tzutc()),
51 | ):
52 | assert p.skip_path(path)
53 | with settings(
54 | MAX_DATE=datetime.datetime(1995, 1, 1, tzinfo=tzutc()),
55 | ):
56 | assert p.skip_path(path)
57 |
58 |
59 | def test_observations_parser_skips_rows_if_before_cutoff(data_dir):
60 | p = WindObservationsParser()
61 | path = data_dir / 'observations_recent_FF_akt.zip'
62 | with settings(
63 | MIN_DATE=datetime.datetime(2019, 1, 1, tzinfo=tzutc()),
64 | ):
65 | records = list(p.parse(path))
66 | assert len(records) == 5
67 | assert records[0]['timestamp'] == datetime.datetime(
68 | 2019, 4, 20, 21, tzinfo=tzutc())
69 | with settings(
70 | MAX_DATE=datetime.datetime(2019, 1, 1, tzinfo=tzutc()),
71 | ):
72 | records = list(p.parse(path))
73 | assert len(records) == 5
74 | assert records[-1]['timestamp'] == datetime.datetime(
75 | 2018, 9, 15, 4, tzinfo=tzutc())
76 |
77 |
78 | def test_radolan_parser(data_dir):
79 | p = RADOLANParser()
80 | records = list(p.parse(data_dir / 'DE1200_RV2305081330.tar.bz2'))
81 | assert len(records) == 1
82 | data = np.frombuffer(
83 | zlib.decompress(records[0]['precipitation_5']),
84 | dtype='i2',
85 | )
86 | assert len(data) == 1200 * 1100
87 | assert np.sum(data) == 564030
88 | assert len(np.where(data < 4096)[0]) == len(data)
89 | assert data.reshape((1200, 1100))[1117:1122, 334:339].tolist() == [
90 | [3, 5, 2, 1, 3],
91 | [2, 3, 3, 0, 0],
92 | [3, 4, 1, 0, 3],
93 | [0, 8, 0, 0, 0],
94 | [0, 0, 0, 0, 0],
95 | ]
96 |
97 |
98 | @freeze_time('2023-06-11')
99 | def test_solar_radiation_parser_skips_today(data_dir):
100 | p = SolarRadiationObservationsParser()
101 | records = list(p.parse(
102 | data_dir / '10minutenwerte_SOLAR_01766_akt.zip',
103 | meta_path=data_dir / 'Meta_Daten_zehn_min_sd_01766.zip'
104 | ))
105 | assert records[-1]['timestamp'] == datetime.datetime(
106 | 2023, 6, 10, 23, tzinfo=tzutc())
107 |
108 |
109 | def test_get_parser():
110 | synop_with_timestamp = (
111 | 'Z__C_EDZW_20200617114802_bda01,synop_bufr_GER_999999_999999__MW_617'
112 | '.json.bz2')
113 | synop_latest = (
114 | 'Z__C_EDZW_latest_bda01,synop_bufr_GER_999999_999999__MW_XXX.json.bz2')
115 | expected = {
116 | '10minutenwerte_extrema_wind_00427_akt.zip': (
117 | WindGustsObservationsParser),
118 | '10minutenwerte_SOLAR_01766_now.zip': SolarRadiationObservationsParser,
119 | 'stundenwerte_FF_00011_akt.zip': WindObservationsParser,
120 | 'stundenwerte_FF_00090_akt.zip': WindObservationsParser,
121 | 'stundenwerte_N_01766_akt.zip': CloudCoverObservationsParser,
122 | 'stundenwerte_P0_00096_akt.zip': PressureObservationsParser,
123 | 'stundenwerte_RR_00102_akt.zip': PrecipitationObservationsParser,
124 | 'stundenwerte_SD_00125_akt.zip': SunshineObservationsParser,
125 | 'stundenwerte_TD_01766.zip': DewPointObservationsParser,
126 | 'stundenwerte_TU_00161_akt.zip': TemperatureObservationsParser,
127 | 'stundenwerte_VV_00161_akt.zip': VisibilityObservationsParser,
128 | 'MOSMIX_S_LATEST_240.kmz': MOSMIXParser,
129 | 'DE1200_RV2305081330.tar.bz2': RADOLANParser,
130 | 'K611_-BEOB.csv': CurrentObservationsParser,
131 | synop_with_timestamp: SYNOPParser,
132 | synop_latest: None,
133 | }
134 | for filename, expected_parser in expected.items():
135 | assert get_parser(filename) is expected_parser
136 |
--------------------------------------------------------------------------------
/tests/test_polling.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from unittest.mock import patch
3 |
4 | from dateutil.tz import tzutc
5 |
6 | from brightsky.polling import DWDPoller
7 |
8 |
9 | def test_dwdpoller_parse(data_dir):
10 | with open(data_dir / 'dwd_opendata_index.html') as f:
11 | resp_text = f.read()
12 | expected = {
13 | '/dir/stundenwerte_FF_00011_akt.zip': (
14 | 'WindObservationsParser', '2020-03-29 08:55', 70523),
15 | '/dir/stundenwerte_FF_00090_akt.zip': (
16 | 'WindObservationsParser', '2020-03-29 08:56', 71408),
17 | '/dir/stundenwerte_P0_00096_akt.zip': (
18 | 'PressureObservationsParser', '2020-03-29 08:57', 47355),
19 | '/dir/stundenwerte_RR_00102_akt.zip': (
20 | 'PrecipitationObservationsParser', '2020-03-29 08:58', 74372),
21 | '/dir/stundenwerte_SD_00125_akt.zip': (
22 | 'SunshineObservationsParser', '2020-03-29 08:59', 69633),
23 | '/dir/stundenwerte_TD_00163_akt.zip': (
24 | 'DewPointObservationsParser', '2020-03-29 09:02', 70167),
25 | '/dir/stundenwerte_TU_00161_akt.zip': (
26 | 'TemperatureObservationsParser', '2020-03-29 09:00', 70165),
27 | '/dir/stundenwerte_VV_00164_akt.zip': (
28 | 'VisibilityObservationsParser', '2020-03-29 09:03', 70168),
29 | '/dir/stundenwerte_N_00162_akt.zip': (
30 | 'CloudCoverObservationsParser', '2020-03-29 09:01', 70166),
31 | '/dir/MOSMIX_L_LATEST.kmz': (
32 | 'MOSMIXParser', '2020-03-29 09:57', 91636300),
33 | '/dir/MOSMIX_S_LATEST_240.kmz': (
34 | 'MOSMIXParser', '2020-03-29 10:21', 38067304),
35 | '/dir/K611_-BEOB.csv': (
36 | 'CurrentObservationsParser', '2020-04-06 10:38', 7343),
37 | '/dir/10minutenwerte_extrema_wind_01766_now.zip': (
38 | 'WindGustsObservationsParser', '2020-06-08 09:12', 701),
39 | '/dir/10minutenwerte_extrema_wind_01766_akt.zip': (
40 | 'WindGustsObservationsParser', '2020-06-08 04:27', 519692),
41 | '/dir/10minutenwerte_extrema_wind_01766_20100101_20191231_hist.zip': (
42 | 'WindGustsObservationsParser', '2020-04-09 09:16', 3661729),
43 | '/dir/10minutenwerte_SOLAR_01766_akt.zip': (
44 | 'SolarRadiationObservationsParser', '2023-04-12 00:50', 367557),
45 | }
46 | assert list(DWDPoller().parse('/dir/', resp_text)) == [
47 | {
48 | 'url': k,
49 | 'parser': v[0],
50 | 'last_modified': datetime.datetime.strptime(
51 | v[1], '%Y-%m-%d %H:%M').replace(tzinfo=tzutc()),
52 | 'file_size': v[2],
53 | }
54 | for k, v in expected.items()]
55 |
56 |
57 | def test_dwdpoller_poll_ignores_parsed_files(db, data_dir):
58 | poller = DWDPoller()
59 | poller.urls = ['http://example.com/']
60 | url = 'http://example.com/stundenwerte_FF_00011_akt.zip'
61 | with open(data_dir / 'dwd_opendata_index.html') as f:
62 | resp_text = f.read()
63 | config = {'get.return_value.text': resp_text}
64 | with patch('brightsky.polling.requests', **config):
65 | urls = [info['url'] for info in poller.poll()]
66 | with db.cursor() as cur:
67 | cur.execute(
68 | """
69 | INSERT INTO parsed_files (
70 | url, last_modified, file_size, parsed_at)
71 | VALUES (%s, %s, %s, current_timestamp)
72 | """,
73 | (url, '2020-03-29 08:55', 70523))
74 | db.commit()
75 | new_urls = [info['url'] for info in poller.poll()]
76 | assert url in urls
77 | assert url not in new_urls
78 | assert len(new_urls) == len(urls) - 1
79 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from dateutil.tz import tzutc
4 |
5 | from brightsky.settings import Settings
6 |
7 | from .utils import environ
8 |
9 |
10 | def test_settings_loads_environment():
11 | with environ(BRIGHTSKY_TEST='value'):
12 | assert Settings().TEST == 'value'
13 |
14 |
15 | def test_settings_parses_environment_bool():
16 | assert isinstance(Settings().KEEP_DOWNLOADS, bool)
17 | with environ(BRIGHTSKY_KEEP_DOWNLOADS='0'):
18 | assert Settings().KEEP_DOWNLOADS is False
19 | with environ(BRIGHTSKY_KEEP_DOWNLOADS='1'):
20 | assert Settings().KEEP_DOWNLOADS is True
21 |
22 |
23 | def test_settings_parses_environment_date():
24 | expected = datetime.datetime(2000, 1, 2, tzinfo=tzutc())
25 | assert isinstance(Settings().MIN_DATE, datetime.datetime)
26 | with environ(BRIGHTSKY_MIN_DATE='2000-01-02'):
27 | assert Settings().MIN_DATE == expected
28 | assert Settings().MAX_DATE is None
29 | with environ(BRIGHTSKY_MAX_DATE='2000-01-02'):
30 | assert Settings().MAX_DATE == expected
31 |
32 |
33 | def test_settings_parses_environment_float():
34 | assert isinstance(Settings().ICON_RAIN_THRESHOLD, float)
35 | with environ(BRIGHTSKY_ICON_RAIN_THRESHOLD='0'):
36 | assert Settings().ICON_RAIN_THRESHOLD == float('0')
37 | with environ(BRIGHTSKY_ICON_RAIN_THRESHOLD='1.5'):
38 | assert Settings().ICON_RAIN_THRESHOLD == float('1.5')
39 |
40 |
41 | def test_settings_parses_environment_list():
42 | assert isinstance(Settings().CORS_ALLOWED_ORIGINS, list)
43 | with environ(BRIGHTSKY_CORS_ALLOWED_ORIGINS=''):
44 | assert Settings().CORS_ALLOWED_ORIGINS == []
45 | with environ(BRIGHTSKY_CORS_ALLOWED_ORIGINS='a'):
46 | assert Settings().CORS_ALLOWED_ORIGINS == ['a']
47 | with environ(BRIGHTSKY_CORS_ALLOWED_ORIGINS='a,b,c'):
48 | assert Settings().CORS_ALLOWED_ORIGINS == ['a', 'b', 'c']
49 |
--------------------------------------------------------------------------------
/tests/test_tasks.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from dateutil.tz import tzutc
4 |
5 | from brightsky.export import DBExporter, SYNOPExporter
6 | from brightsky.tasks import clean
7 |
8 |
9 | def test_clean_deletes_expired_parsed_files(db):
10 | now = datetime.datetime.now(datetime.UTC).replace(tzinfo=tzutc())
11 | fingerprints = [
12 | {
13 | 'url': 'https://example.com/Z__C_EDZW_very_old.json',
14 | 'last_modified': now,
15 | 'file_size': 1234,
16 | 'parsed_at': now - datetime.timedelta(days=14),
17 | },
18 | {
19 | 'url': 'https://example.com/Z__C_EDZW_recent.json',
20 | 'last_modified': now,
21 | 'file_size': 1234,
22 | 'parsed_at': now - datetime.timedelta(days=1),
23 | },
24 | ]
25 | db.insert('parsed_files', fingerprints)
26 | assert len(db.table('parsed_files')) == 2
27 | clean()
28 | rows = db.table('parsed_files')
29 | assert len(rows) == 1
30 | assert rows[0]['url'].endswith('_recent.json')
31 |
32 |
33 | PLACE = {
34 | 'lat': 10,
35 | 'lon': 20,
36 | 'height': 30,
37 | 'dwd_station_id': '01766',
38 | 'wmo_station_id': '10315',
39 | 'station_name': 'Münster',
40 | }
41 |
42 |
43 | def test_clean_deletes_expired_forecast_current_synop_records(db):
44 | now = datetime.datetime.now(datetime.UTC).replace(
45 | minute=0, second=0, microsecond=0, tzinfo=tzutc())
46 | records = [
47 | {
48 | 'observation_type': 'forecast',
49 | 'timestamp': now,
50 | **PLACE,
51 | 'temperature': 10.,
52 | },
53 | {
54 | 'observation_type': 'forecast',
55 | 'timestamp': now - datetime.timedelta(hours=6),
56 | **PLACE,
57 | 'temperature': 20.,
58 | },
59 | {
60 | 'observation_type': 'current',
61 | 'timestamp': now,
62 | **PLACE,
63 | 'temperature': 30.,
64 | },
65 | {
66 | 'observation_type': 'current',
67 | 'timestamp': now - datetime.timedelta(hours=6),
68 | **PLACE,
69 | 'temperature': 40.,
70 | },
71 | {
72 | 'observation_type': 'current',
73 | 'timestamp': now - datetime.timedelta(days=3),
74 | **PLACE,
75 | 'temperature': 50.,
76 | },
77 | ]
78 | synop_records = [
79 | {
80 | 'observation_type': 'synop',
81 | 'timestamp': now,
82 | **PLACE,
83 | 'temperature': 60.,
84 | },
85 | {
86 | 'observation_type': 'synop',
87 | 'timestamp': now - datetime.timedelta(hours=6),
88 | **PLACE,
89 | 'temperature': 70.,
90 | },
91 | {
92 | 'observation_type': 'synop',
93 | 'timestamp': now - datetime.timedelta(days=3),
94 | **PLACE,
95 | 'temperature': 80.,
96 | },
97 | ]
98 | DBExporter().export(records)
99 | SYNOPExporter().export(synop_records)
100 | assert len(db.table('weather')) == 5
101 | assert len(db.table('synop')) == 3
102 | clean()
103 | assert len(db.table('weather')) == 3
104 | assert len(db.table('synop')) == 2
105 | rows = db.fetch('SELECT temperature FROM weather ORDER BY temperature')
106 | assert [r['temperature'] for r in rows] == [10., 30., 40.]
107 | rows = db.fetch('SELECT temperature FROM synop ORDER BY temperature')
108 | assert [r['temperature'] for r in rows] == [60., 70.]
109 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from dateutil.tz import tzoffset, tzutc
3 |
4 | from brightsky.utils import daytime, parse_date, sunrise_sunset
5 |
6 |
7 | def test_parse_date():
8 | assert parse_date('2020-08-18') == datetime.datetime(2020, 8, 18, 0, 0)
9 | assert parse_date('2020-08-18 12:34') == datetime.datetime(
10 | 2020, 8, 18, 12, 34)
11 | assert parse_date('2020-08-18T12:34:56+02:00') == datetime.datetime(
12 | 2020, 8, 18, 12, 34, 56, tzinfo=tzoffset(None, 7200))
13 | assert parse_date('2020-08-18T12:34:56 02:00') == datetime.datetime(
14 | 2020, 8, 18, 12, 34, 56, tzinfo=tzoffset(None, 7200))
15 |
16 |
17 | def test_sunrise_sunset():
18 | sunrise, sunset = sunrise_sunset(52, 7.6, datetime.date(2020, 8, 18))
19 | assert sunrise < sunset
20 | assert sunrise.utcoffset().total_seconds() == 0
21 | assert sunset.utcoffset().total_seconds() == 0
22 |
23 |
24 | def test_daytime():
25 | midnight_0 = datetime.datetime(2023, 7, 10, 0, 0, tzinfo=tzutc())
26 | noon_0 = datetime.datetime(2023, 7, 10, 12, 0, tzinfo=tzutc())
27 | midnight_10 = datetime.datetime(2023, 7, 9, 14, 0, tzinfo=tzutc())
28 | noon_10 = datetime.datetime(2023, 7, 10, 2, 0, tzinfo=tzutc())
29 | # Muenster
30 | assert daytime(52, 7.6, midnight_0) == 'night'
31 | assert daytime(52, 7.6, noon_0) == 'day'
32 | # Sydney
33 | assert daytime(-33.8, 151, midnight_10) == 'night'
34 | assert daytime(-33.8, 151, noon_10) == 'day'
35 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | from contextlib import contextmanager
3 | from functools import partial
4 |
5 | from brightsky.settings import settings as bs_settings
6 |
7 |
8 | def is_subset(subset_dict, full_dict):
9 | return subset_dict == {k: full_dict[k] for k in subset_dict}
10 |
11 |
12 | @contextmanager
13 | def dict_override(d, **overrides):
14 | original = {k: d[k] for k, v in overrides.items() if k in d}
15 | d.update(overrides)
16 | try:
17 | yield
18 | finally:
19 | for k in overrides:
20 | del d[k]
21 | d.update(original)
22 |
23 |
24 | settings = partial(dict_override, bs_settings)
25 | environ = partial(dict_override, os.environ)
26 |
--------------------------------------------------------------------------------