Interactive web dashboard for exploring long-term climate trends across 20 locations in North Macedonia, powered by ERA5-Land reanalysis data (1950–present).
Live: climate.mk
- Regression chart — daily temperature, precipitation and evapotranspiration trends per station with Theil-Sen + Mann-Kendall or OLS; scatter points, CI band, ±N-day window filter
- Year-round trend calendar — one bar per day of year, coloured by trend direction and significance; one panel per selected location
- Station map — all 20 stations coloured by trend slope; click or tap to select
- Hero cards — trend slope, p-value and significance summary for each selected location
- "Is it hot in Macedonia today?" — compares the current national daily maximum temperature against the ERA5-Land historical distribution for the same calendar day (±window), with KDE curve, percentile rank and a plain-language verdict
- Last 7 days mini chart — small chart showing the temperature category (Freezing…Hell) for each of the last 7 days, dates ascending, with colour-banded category rows
- Seasonal heat ranking — one coloured cell per (year, season) from 1950 to present; percentile rank of each season's mean national daily-maximum temperature against the 1950–1980 baseline; colour: blue = cold, orange/red = hot/extreme; animate, filter, stats, tooltip
- Seasonal drought index (SPEI) — same grid layout but showing the SPEI (Standardized Precipitation-Evapotranspiration Index) per season; dry = orange, wet = blue; 1950–1980 baseline
- Per-station SPEI trend — Highcharts scatter + Theil-Sen trend line per station and period; two time scales:
- SPEI-3 (seasonal, ~90 days): Annual, Winter, Spring, Summer, Autumn
- SPEI-30 (monthly, calendar month): Jan through Dec
- Stats box: slope per decade, Mann-Kendall trend direction, significance
- Extrapolation: estimates the year the trend line crosses the extreme drought (SPEI −1.5) or extremely wet (SPEI +1.5) threshold, guarded against zero-slope division
- Chart export menu — PNG/JPEG/PDF/SVG image and CSV downloads, data table view, full screen, print (Highcharts exporting modules, client-side only); not available on the seasonal heat ranking and SPEI heatmaps (custom HTML grids, not Highcharts)
- Multi-language UI: English, Macedonian (МК), Albanian (SQ) via JSON locale files
- Mobile-responsive with hamburger drawer, vertical season labels on heatmaps
- Chat with Ognen — AI climate assistant (Azure Bot Framework / Direct Line)
- Welcome modal, "In the next episodes…" teaser section
- Dark/light variable theming via CSS custom properties
- Climate news (
climate-news.html, MK only) — recent climate-related headlines aggregated from Macedonian news outlet RSS feeds, plus the site's X (Twitter) timeline - Heat wave safety (
heat-wave-poster.html) — standalone public-safety page: the national heat-health alert scale, Institute of Public Health recommendations, a 9-step prevention/preparedness/first-aid guide and heat-stroke emergency instructions. Self-contained (own CSS and MK/SQ/EN strings inline, independent of the locale JSON files); linked from the main menu
A standalone page tracking current and historical wildfires, built on a Leaflet map (the climate pages keep using Highcharts Maps). Feature-flagged via fires_map, fires_year_chart, fires_danger, fires_satellite, fires_settlement — turn any off in countries/<cc>.yaml to hide the layer, its endpoint, and its nav link.
- Interactive fire map — NASA FIRMS active-fire detections (MODIS + VIIRS SNPP / NOAA-20 / NOAA-21) plotted as points coloured by fire radiative power, with a date/period picker and a per-satellite filter
- Map layers — base toggle (OpenStreetMap streets ↔ Esri satellite) and toggleable WMS overlays: EFFIS Fire Weather Index (fire-danger forecast), Sentinel-3 hotspots, and EFFIS burnt areas (fire footprint). GHSL built-up and WDPA protected-areas overlays are currently disabled (
fires_settlement,fires_protected_areas: EFFIS removed those WMS layers in 2026; re-enable only with a replacement source) - Fires-per-year chart — year-over-year detection totals; served from Global Forest Watch's aggregated VIIRS query when a
GFW_API_KEYis set, otherwise counted from the local FIRMS CSVs (with a sensor-comparability caveat) - Data sources block — attributions/links for FIRMS, EFFIS, GFW and the base maps (plus GHSL while the built-up overlay is enabled), shown below the charts
- Collection —
fire_collect.pymirrorsmk_collect.py: chunked 10-day FIRMS requests per sensor intodata/<cc>/fires/<SENSOR>.csv, differential by default,--force-refreshfor a full backfill, a self-healing_gaps.jsonretry log, and Sentinel-3 pulled from the EFFIS WFS. NeedsFIRMS_MAP_KEYin.env; hourly cron entry atcron/fire_collect.
| Layer | Technology |
|---|---|
| Data source | Open-Meteo ERA5-Land archive API |
| Backend | Python · Flask · pandas · scipy · pymannkendall |
| Frontend | Vanilla JS · Highcharts |
| Hosting | Hetzner CX23 · nginx · systemd |
| CDN / HTTPS | Cloudflare |
ERA5_MK/
├── mk_collect.py # Data collection — fetches ERA5-Land CSVs from Open-Meteo
├── mk_api.py # Flask API — all statistics and route handlers (incl. /api/fires/*)
├── mk_collect.py # ERA5-Land climate collection
├── fire_collect.py # Wildfire collection — FIRMS + Sentinel-3 → data/<cc>/fires/
├── climate_news.py # Standalone climate-news aggregation (MK outlet RSS → cache)
├── config.py # Loads countries/<cc>.yaml into one CONFIG object
├── countries/<cc>.yaml # Per-country config: features, stations, fires block
├── requirements.txt # Python dependencies
├── cron/
│ ├── mk_collect # cron.d file — runs mk_collect.py nightly
│ ├── fire_collect # cron.d file — runs fire_collect.py hourly
│ └── climate_news # cron.d file — refreshes climate-news cache every 6h
├── static/
│ ├── index.html # Single-page app shell
│ ├── fires.html # Standalone wildfire tracker page (/fires)
│ ├── user-manual.html # Standalone user manual page
│ ├── climate-news.html # Standalone climate-news page (MK only)
│ ├── heat-wave-poster.html # Standalone heat-wave safety page (self-contained MK/SQ/EN)
│ ├── js/
│ │ ├── app.js # Climate chart logic, API calls, UI interactions
│ │ ├── fires.js # Wildfire page logic (Leaflet map, controls, year chart)
│ │ └── vendor/ # Highcharts, Leaflet (self-hosted, no CDN)
│ ├── css/style.css # Light-theme responsive CSS
│ └── locales/ # JSON translation files (en, mk, sq)
├── data/<cc>/ # ERA5-Land CSVs per station; fires/ subdir for detections (gitignored)
└── cache/<cc>/ # Auto-generated JSON cache files (gitignored)
| Endpoint | Parameters | Returns |
|---|---|---|
GET /api/meta |
— | Location list, variable labels, colour palette |
GET /api/regression |
loc, var, doy, window, corr, method |
Scatter points, trend line, CI band, stats |
GET /api/calendar |
loc, var, window, corr, method |
365-day trend array for the calendar chart |
GET /api/trends |
var, doy, window, method |
Trend slope per station for the map |
GET /api/annual_trend |
— | Annual mean temperature trend (national) |
GET /api/today_status |
date, loc |
Today's (or a given date's) temperature vs historical distribution (KDE, percentile, category) |
GET /api/today_status/last7 |
date, loc |
Category/percentile for each of the 7 days ending at date (or today), ascending |
GET /api/today_status/refresh |
key |
Cron-only: force-refetches today's national forecast (requires TODAY_REFRESH_KEY). Returns computed_at (Skopje time) plus previous_national_today_temp/new_national_today_temp, so a cron run can be confirmed from the response. /api/today_status itself also carries computed_at/computed_at_tz for the same check. |
To check from a browser whether the cron actually ran on prod, browse to https://climate.mk/api/today_status and inspect computed_at/computed_at_tz in the JSON (no key needed). To manually trigger and see the before/after comparison, browse to https://climate.mk/api/today_status/refresh?key=<TODAY_REFRESH_KEY> (the real key is in the server's .env, never commit it here).
| GET /api/season_heatmap | — | Seasonal temperature percentiles vs 1950–1980 baseline (all years) |
| GET /api/spei_heatmap | — | Seasonal SPEI vs 1950–1980 baseline (all years) |
| GET /api/spei_station_seasonal | — | Per-station SPEI-3 (seasonal) + SPEI-30 (monthly) series with Theil-Sen trend |
| GET /api/token | — | Short-lived Direct Line token for the chatbot (rate-limited) |
| GET /api/data/download | — | Zip archive of all station CSVs (tmax, tmin, tmean, precip, ET₀; 1950–present) |
| GET /api/climate_news | — | Recent Macedonian climate-news headlines, aggregated from MK outlet RSS feeds and filtered by keyword (archive refreshed by cron every 6h via climate_news.py) |
| GET /api/fires/points | start, end, sensor | FIRMS/Sentinel-3 detections for a date range (lat/lon, date, sensor, FRP, confidence); capped and gated by fires_map |
| GET /api/fires/yearly | — | Per-year detection totals for the comparison chart (GFW aggregation when GFW_API_KEY set, else local FIRMS counts); gated by fires_year_chart |
| GET /api/fires/danger_meta | — | EFFIS Fire Weather Index WMS URL + layer for the fire-danger overlay; gated by fires_danger |
Computed results are cached at two levels:
- In-memory (
_TODAY_CACHEdict) — survives for the lifetime of the process; clears on restart - Disk (
cache/directory, JSON files) — survives restarts; filename contains theera5_lastdate so the cache auto-invalidates when new ERA5 data arrives
| Cache file | Typical size | Cold compute time |
|---|---|---|
today_YYYY-MM-DD.json |
4 KB | ~1s |
season_heatmap_YYYY-MM-DD.json |
45 KB | ~0.2s |
spei_heatmap_YYYY-MM-DD.json |
45 KB | ~0.2s |
spei_station_seasonal_v2_YYYY-MM-DD.json |
357 KB | ~6 min on server |
The GitHub Actions deploy workflow automatically warms all slow caches after each deployment so users never hit the cold path.
SPEI (Standardized Precipitation-Evapotranspiration Index) was introduced by Vicente-Serrano, Beguería & López-Moreno (2010, Journal of Climate, doi:10.1175/2009JCLI2909.1).
Implementation here:
- Daily water balance D = P − ET₀ (national mean precipitation minus mean reference evapotranspiration across all 20 stations, or per-station for the trend chart)
- Seasonal / monthly sum of D (mm)
- 3-parameter log-logistic distribution fitted to the 1950–1980 baseline values per season/month using
scipy.stats.fiskwith a shift parameter so all values are positive - CDF transformed to standard normal via
scipy.stats.norm.ppf→ SPEI score, clipped to ±3
Thresholds follow WMO convention:
| SPEI | Category |
|---|---|
| < −1.5 | Extreme drought |
| −1.5 to −1.0 | Dry |
| −1.0 to +1.0 | Normal |
| +1.0 to +1.5 | Wet |
| > +1.5 | Extremely wet |
Using a fixed 1950–1980 baseline (rather than the full record) means colours reflect change relative to the pre-warming reference period, making the drying trend visually explicit.
ERA5-Land reanalysis data is fetched per location via the Open-Meteo archive API and stored as one CSV per location in ./data/. Variables: temperature_max, temperature_min, temperature_mean, precipitation_sum, et0_evapotranspiration.
The collection script supports differential updates:
source venv/bin/activate
python3 mk_collect.pyThis detects the latest date in each CSV and fetches only new data. A cron job on the server runs this nightly.
Force a complete re-fetch:
python3 mk_collect.py --force-refreshgit clone git@github.com:kesma01/ERA5_MK.git
cd ERA5_MK
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 mk_collect.py # fetch/update data
python3 mk_api.py # start server → http://127.0.0.1:5050To test on a phone on the same Wi-Fi:
ipconfig getifaddr en0 # find your local IP
# then open http://<local-ip>:5050 on the deviceTo enable the AI chatbot, create a .env file:
DIRECT_LINE_SECRET=your_secret_here
Theil-Sen + TFPW Mann-Kendall (default):
- Theil-Sen slope is robust to outliers
- Yue-Wang TFPW corrects for AR(1) autocorrelation, giving properly calibrated p-values
- Computed on annual means to avoid pseudo-replication from daily values
OLS is provided for comparison.
CI/CD via GitHub Actions (.github/workflows/deploy.yml): on push to main, rsync files to the Hetzner server, write .env, restart systemd service, run health check, warm slow caches.
The app runs on Hetzner CX23 behind nginx, served through Cloudflare with Full (strict) SSL:
Browser → Cloudflare (HTTPS) → nginx (HTTPS, Origin Cert) → Flask (HTTP, localhost:5050)
See the workflow file for the full deploy sequence.
Climate data: Open-Meteo ERA5-Land — free, open reanalysis data from ECMWF.
SPEI index: Vicente-Serrano, S.M., Beguería, S., López-Moreno, J.I. (2010). A Multiscalar Drought Index Sensitive to Global Warming: The Standardized Precipitation Evapotranspiration Index. Journal of Climate, 23(7), 1696–1718.