Skip to main content
edited title
Link
toolic
  • 15.9k
  • 6
  • 29
  • 217

Yet Another Python Weather Visualizer using Open-Meteo

Became Hot Network Question
Source Link
Ben A
  • 10.8k
  • 5
  • 38
  • 103

Yet Another Python Weather Visualizer

As a personal project to build my portfolio, I've created a simple weather app that displays temperature, precipitation, and wind for any given city. This is my first time building a GUI using tkinter, so any suggestions and criticisms are welcome.

No API key is required, as this uses the open-meteo free public API.

"""
Weather Data Visualization

@author Ben Antonellis
@date September 23rd, 2025

Usage:
    python3 weather.py

CSV Format and Example Data:
    date,tmax,tmin,precip,wind
    2021-09-22,22.5,14.1,2.0,11.2
"""

import csv
import threading
from datetime import datetime
from io import StringIO
from tkinter import filedialog, messagebox
import tkinter as tk
from tkinter import ttk

import requests
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure

TITLE = "Weather Visualizer"

# ----------------------------- Helpers ----------------------------- #

def c_to_f(c: float) -> float:
    return c * 9.0 / 5.0 + 32.0


def to_unit(val, unit: str) -> float | None:
    if val is None:
        return None
    return c_to_f(val) if unit == "F" else val


def parse_csv_text(text: str) -> list[dict[str, datetime | str]]:
    """ Parse CSV with columns: date,tmax,tmin,precip,wind """
    rdr = csv.DictReader(StringIO(text))
    required = { "date", "tmax", "tmin", "precip", "wind" }
    if not required.issubset({c.strip().lower() for c in rdr.fieldnames or []}):
        raise ValueError("CSV must have columns: date,tmax,tmin,precip,wind")
    rows = []
    for row in rdr:
        try:
            date = datetime.fromisoformat(row["date"].strip()).date()
        except Exception:
            # try common formats
            date = datetime.strptime(row["date"].strip(), "%Y-%m-%d").date()
        rows.append(
            {
                "date": date,
                "tmax": float(row["tmax"]),
                "tmin": float(row["tmin"]),
                "precip": float(row["precip"]),
                "wind": float(row["wind"]),
            }
        )
    rows.sort(key=lambda r: r["date"])
    return rows

# --------------------------- API functions --------------------------- #

def geocode_city(q: str) -> dict:
    url = "https://geocoding-api.open-meteo.com/v1/search"
    params = { "name": q, "count": 1, "language": "en", "format": "json" }
    r = requests.get(url, params=params, timeout=20)
    r.raise_for_status()
    data = r.json()
    hit = (data.get("results") or [None])[0]
    if not hit:
        raise ValueError("City not found")
    return {
        "name": hit["name"],
        "country": hit.get("country_code", ""),
        "lat": hit["latitude"],
        "lon": hit["longitude"],
    }


def fetch_forecast(lat: float, lon: float) -> list[dict[str, datetime | float]]:
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": lat,
        "longitude": lon,
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max",
        "timezone": "auto",
        "forecast_days": 7,
    }
    r = requests.get(url, params=params, timeout=20)
    r.raise_for_status()
    j = r.json()["daily"]
    rows = []
    for i, t in enumerate(j["time"]):
        rows.append(
            {
                "date": datetime.fromisoformat(t).date(),
                "tmax": float(j["temperature_2m_max"][i]),
                "tmin": float(j["temperature_2m_min"][i]),
                "precip": float(j["precipitation_sum"][i]),
                "wind": float(j["windspeed_10m_max"][i]),
            }
        )
    return rows

# ---------------------------- UI Class ---------------------------- #

class WeatherApp(tk.Tk):

    def __init__(self):
        super().__init__()
        self.title(TITLE)
        self.geometry("1080x720")

        # State
        self.unit = tk.StringVar(value="C")
        self.place_label = tk.StringVar(value="Demo")
        self.city_query = tk.StringVar(value="")
        self.status = tk.StringVar(value="Ready")
        self.rows = None

        self._build_ui()
        self._render_all()

    # ------------------------ UI Construction ------------------------ #

    def _build_ui(self) -> None:
        self.columnconfigure(0, weight=1)
        self.rowconfigure(2, weight=1)

        # Top bar
        top = ttk.Frame(self, padding=10)
        top.grid(row=0, column=0, sticky="ew")
        top.columnconfigure(1, weight=1)

        ttk.Label(top, text="City:").grid(row=0, column=0, padx=(0, 6))
        entry = ttk.Entry(top, textvariable=self.city_query)
        entry.grid(row=0, column=1, sticky="ew")
        entry.bind("<Return>", lambda _: self.search_city())
        ttk.Button(top, text="Search", command=self.search_city).grid(row=0, column=2, padx=6)

        ttk.Button(top, text="Load CSV…", command=self.load_csv).grid(row=0, column=3, padx=6)

        ttk.Label(top, text="Units:").grid(row=0, column=4, padx=(12, 6))
        unit_box = ttk.Combobox(top, textvariable=self.unit, values=("C", "F"), width=4, state="readonly")
        unit_box.grid(row=0, column=5)
        unit_box.bind("<<ComboboxSelected>>", lambda _: self._render_all())

        ttk.Label(top, textvariable=self.place_label, font=("TkDefaultFont", 10, "italic")).grid(row=0, column=6, padx=(12, 0))

        # Stats bar
        stats = ttk.Frame(self, padding=(10, 0))
        stats.grid(row=1, column=0, sticky="ew")
        stats.columnconfigure((0, 1, 2, 3), weight=1)
        self.stat_tmax = ttk.Label(stats, anchor="center")
        self.stat_tmin = ttk.Label(stats, anchor="center")
        self.stat_prec = ttk.Label(stats, anchor="center")
        self.stat_wind = ttk.Label(stats, anchor="center")
        self.stat_tmax.grid(row=0, column=0, sticky="ew", padx=6, pady=6)
        self.stat_tmin.grid(row=0, column=1, sticky="ew", padx=6, pady=6)
        self.stat_prec.grid(row=0, column=2, sticky="ew", padx=6, pady=6)
        self.stat_wind.grid(row=0, column=3, sticky="ew", padx=6, pady=6)

        # Charts area
        charts = ttk.Panedwindow(self, orient=tk.VERTICAL)
        charts.grid(row=2, column=0, sticky="nsew", padx=10, pady=6)

        self.fig_temp = Figure(figsize=(6, 2.6), dpi=100)
        self.ax_temp = self.fig_temp.add_subplot(111)
        self.cv_temp = FigureCanvasTkAgg(self.fig_temp, master=charts)
        charts.add(self.cv_temp.get_tk_widget())

        self.fig_prec = Figure(figsize=(6, 2.2), dpi=100)
        self.ax_prec = self.fig_prec.add_subplot(111)
        self.cv_prec = FigureCanvasTkAgg(self.fig_prec, master=charts)
        charts.add(self.cv_prec.get_tk_widget())

        self.fig_wind = Figure(figsize=(6, 5.2), dpi=100)
        self.ax_wind = self.fig_wind.add_subplot(1, 1, 1)
        self.cv_wind = FigureCanvasTkAgg(self.fig_wind, master=charts)
        charts.add(self.cv_wind.get_tk_widget())

        # Status bar
        status = ttk.Frame(self, padding=8)
        status.grid(row=3, column=0, sticky="ew")
        self.status_lbl = ttk.Label(status, textvariable=self.status, anchor="w")
        self.status_lbl.pack(fill="x")

    # --------------------------- Rendering --------------------------- #

    def _render_stats(self) -> None:
        if not self.rows:
            self.stat_tmax["text"] = "Latest Tmax: —"
            self.stat_tmin["text"] = "Latest Tmin: —"
            self.stat_prec["text"] = "Latest Precip: —"
            self.stat_wind["text"] = "Latest Wind: —"
            return
        latest = sorted(self.rows, key=lambda r: r["date"])[-1]
        unit = self.unit.get()
        tmax = to_unit(latest["tmax"], unit)
        tmin = to_unit(latest["tmin"], unit)
        self.stat_tmax["text"] = f"Latest Tmax: {tmax:.1f} °{unit}"
        self.stat_tmin["text"] = f"Latest Tmin: {tmin:.1f} °{unit}"
        self.stat_prec["text"] = f"Latest Precip: {latest['precip']:.1f} mm"
        self.stat_wind["text"] = f"Latest Wind: {latest['wind']:.1f} km/h"

    def _render_charts(self) -> None:
        if not self.rows:
            for ax in (self.ax_temp, self.ax_prec, self.ax_wind):
                ax.clear()
            for cv in (self.cv_temp, self.cv_prec, self.cv_wind):
                cv.draw()
            return
        unit = self.unit.get()
        # Prepare series
        dates = [r["date"] for r in self.rows]
        tmax = [to_unit(r["tmax"], unit) for r in self.rows]
        tmin = [to_unit(r["tmin"], unit) for r in self.rows]
        precip = [r["precip"] for r in self.rows]
        wind = [r["wind"] for r in self.rows]

        # Temperature line chart
        self.ax_temp.clear()
        self.ax_temp.plot(dates, tmax, label=f"Tmax (°{unit})")
        self.ax_temp.plot(dates, tmin, label=f"Tmin (°{unit})")
        self.ax_temp.set_title("Daily Temperature")
        self.ax_temp.set_xlabel("Date")
        self.ax_temp.set_ylabel(f"°{unit}")
        self.ax_temp.legend(loc="best")
        self.ax_temp.grid(True, alpha=0.25)
        self.fig_temp.tight_layout()
        self.cv_temp.draw()

        # Precipitation bar chart
        self.ax_prec.clear()
        self.ax_prec.bar(dates, precip)
        self.ax_prec.set_title("Daily Precipitation")
        self.ax_prec.set_xlabel("Date")
        self.ax_prec.set_ylabel("mm")
        self.ax_prec.grid(True, axis="y", alpha=0.25)
        self.fig_prec.tight_layout()
        self.cv_prec.draw()

        # Wind area chart (fill between)
        self.ax_wind.clear()
        self.ax_wind.fill_between(dates, wind, step="pre", alpha=0.4)
        self.ax_wind.plot(dates, wind)
        self.ax_wind.set_title("Daily Wind")
        self.ax_wind.set_xlabel("Date")
        self.ax_wind.set_ylabel("km/h")
        self.ax_wind.grid(True, alpha=0.25)
        self.cv_wind.draw()

    def _render_all(self) -> None:
        self._render_stats()
        self._render_charts()

    # --------------------------- Actions --------------------------- #

    def load_csv(self) -> None:
        path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv"), ("All files", "*.*")])
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                text = f.read()
            self.rows = parse_csv_text(text)
            self.place_label.set("Custom CSV")
            self.status.set(f"Loaded {len(self.rows)} rows from {path}")
            self._render_all()
        except Exception as e:
            messagebox.showerror("CSV Error", str(e))

    def search_city(self) -> None:
        if q := self.city_query.get().strip():
            self.status.set("Searching...")
            thread = threading.Thread(target=self._search_city_thread, args=(q,), daemon=True)
            thread.start()

    def _search_city_thread(self, query: str) -> None:
        try:
            g = geocode_city(query)
            rows = fetch_forecast(g["lat"], g["lon"])
            def apply():
                self.rows = rows
                self.place_label.set(f"{g['name']}, {g.get('country','')}")
                self.status.set("Loaded 7‑day forecast")
                self._render_all()
            self.after(0, apply)
        except Exception as e:
            self.after(0, lambda: messagebox.showerror("Search Error", str(e)))
            self.after(0, lambda: self.status.set("Ready"))


if __name__ == "__main__":
    app = WeatherApp()
    app.mainloop()