Week 4: A web app with Flask

Goal of today: a web page in your browser that shows, for each city and each data set: tomorrow's forecasts, a chart of the last 60 days (forecasts against the measured temperature), the average error of each source, and a table of the latest days.

How to work: do the steps in order. After each step, run the code and compare with the expected output. Go on only when yours looks the same. (Your temperatures will differ, because you run it on another day.)


What you need to know

How the web works

When you type an address into the browser:

 browser  ── request:  GET /city/eger ──▶  server (our Python program)
 browser ◀── response: 200 OK + HTML page ──  server

HTML in five minutes

A web page is text with tags. A tag is a name in < >, and most tags have an opening and a closing part: <b>bold</b>.

<h2>Eger</h2>                 a heading
<b>bold text</b><br>          bold, then a line break
<div class="box">...</div>    a block (a box) we can give a style to
<table>                       a table:
  <tr><th>day</th><th>ecmwf</th></tr>      a row (tr) with header cells (th)
  <tr><td>2026-10-05</td><td>23.2</td></tr> a row with normal cells (td)
</table>

The look (colours, borders, spacing) is set with CSS, in the <style> part of the page. You do not need to change it.

Templates

We do not want to write HTML inside Python strings. Instead, the HTML lives in files in the templates folder, and has gaps that Flask fills with our data. This template language is called Jinja:

Jinja Meaning Example
{{ x }} print the value of x <h2>{{ name }}</h2> → <h2>Eger</h2>
{% for ... %} ... {% endfor %} repeat a part one <tr> for every day
{% if ... %} ... {% endif %} show a part only sometimes mark the selected city
{% extends "base.html" %} use the common frame of the pages title, menu, style

JavaScript and Chart.js

JavaScript is the programming language that runs inside the browser. We use it only for one thing: drawing the chart. Chart.js is a ready-made JavaScript library for charts. Python gives the numbers, Chart.js draws them. You do not need to learn JavaScript for this course; we explain the few lines we use.

MAE: how wrong is a forecast?

MAE (mean absolute error): for each day take forecast − measured, drop the sign, take the average.

A small example with only 3 days and made-up numbers (on the web page we use the last 60 days):

day forecast measured error without sign
1 23 22 +1 1
2 20 22 −2 2
3 21 21 0 0

MAE = (1 + 2 + 0) / 3 = 1.0 °C: "on average this source is 1 degree wrong". Lower is better. We drop the sign because +2 and −2 are equally bad; without it they would cancel out and look perfect.


Step 1: Install Flask

Change requirements.txt to:

requirements.txt

requests
tzdata
pandas
flask

And install:

pip install -r requirements.txt

You should see at the end: Successfully installed ... flask-... (or Requirement already satisfied if you installed everything in week 1).


Step 2: The smallest web app

Create app.py:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def home():
    return "Hello from the weather app!"


if __name__ == "__main__":
    app.run(debug=True)

Run it:

python app.py

You should see in the terminal:

 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. ...
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

(The warning is normal: this small server is for development on your own computer.) The program does not end: it waits for requests. Open http://127.0.0.1:5000 in the browser.

The browser shows: Hello from the weather app!

What happened: - app = Flask(__name__) creates the web application. - @app.route("/") attaches the function below it to the address / (the main page). When the browser asks for /, Flask calls home() and sends back what it returns. - debug=True: when you save a change in a .py file, the server restarts by itself, and errors are shown in the browser in detail. So you can leave it running while you do the next steps; just reload the page. (Never use debug=True on a public server.)

To stop the server: Ctrl+C in the terminal.


Step 3: A page for each city

Change app.py to this:

from flask import Flask, abort

from config import CITIES

app = Flask(__name__)


@app.route("/")
def home():
    return "Hello from the weather app!"


@app.route("/city/<city>")
def city_page(city):
    if city not in CITIES:
        abort(404)
    return "This is the page of " + CITIES[city]["name"]


if __name__ == "__main__":
    app.run(debug=True)

Open http://127.0.0.1:5000/city/eger.

The browser shows: This is the page of Eger

Now open http://127.0.0.1:5000/city/szeged.

The browser shows: Not Found. The requested URL was not found on the server...


Step 4: Data from the database

Now show real data. Change app.py to this:

from flask import Flask, abort, request

from config import CITIES, SETS
from db import load_table

app = Flask(__name__)


@app.route("/")
def home():
    return "Hello from the weather app!"


@app.route("/city/<city>")
def city_page(city):
    if city not in CITIES:
        abort(404)
    data_set = request.args.get("set", "models")
    if data_set not in SETS:
        abort(404)
    sources = SETS[data_set]
    table = load_table(city, sources).tail(60)
    return table.tail(5).to_html()


if __name__ == "__main__":
    app.run(debug=True)

Open http://127.0.0.1:5000/city/eger.

The browser shows a simple table with the last 5 days:

             ecmwf   gfs   icon  measured
day
2026-10-03   22.1   22.8  23.4  21.9
2026-10-04   22.5   23.2  24.2  22.4
2026-10-05   23.2   23.9  25.0  23.0
2026-10-06   23.2   24.3  25.2  NaN
2026-10-07   22.2   24.2  24.6  NaN

Now open http://127.0.0.1:5000/city/eger?set=providers. The browser shows the columns open_meteo, met_norway, wttr.


Step 5: The first template

Create a folder called templates next to app.py. Create two files in it.

templates/base.html: the frame of every page (title, style, menu):

templates/base.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Better Forecast</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
  <style>
    body { font-family: sans-serif; max-width: 960px; margin: 20px auto; padding: 0 16px; }
    nav a { margin-right: 12px; }
    nav a.active { font-weight: bold; }
    table { border-collapse: collapse; margin: 10px 0; }
    td, th { border: 1px solid #ccc; padding: 4px 10px; text-align: right; }
    .box { background: #eef5ff; padding: 10px 16px; border-radius: 6px; margin: 10px 0; }
    .best { background: #d8f5d8; }
  </style>
</head>
<body>
  <h1>Better Forecast</h1>
  <nav>
    {% for key, c in cities.items() %}
      <a href="{{ url_for('city_page', city=key, set=data_set) }}" {% if key == city %}class="active"{% endif %}>{{ c.name }}</a>
    {% endfor %}
    |
    {% for name in sets %}
      <a href="{{ url_for('city_page', city=city, set=name) }}" {% if name == data_set %}class="active"{% endif %}>{{ name }}</a>
    {% endfor %}
  </nav>
  {% block content %}{% endblock %}
</body>
</html>

templates/city.html: for now only a heading:

{% extends "base.html" %}
{% block content %}
<h2>{{ cities[city].name }} &ndash; {{ data_set }}</h2>
{% endblock %}

In app.py, add render_template to the first import line:

from flask import Flask, abort, render_template, request

and replace the last line of city_page() (return table.tail(5).to_html()) with:

    return render_template("city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS)

Reload http://127.0.0.1:5000/city/eger.

The browser shows:

Better Forecast
Budapest  Eger  |  models  providers
Eger – models

Click the menu links: the heading changes, and the selected city and set are bold.


Step 6: Tomorrow's forecasts

In app.py, add this import:

from sources import tomorrow

In city_page(), after the line table = load_table(...), replace the return line with:

    tomorrow_forecasts = table.loc[tomorrow()].drop("measured").to_dict() if tomorrow() in table.index else {}

    return render_template(
        "city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS,
        tomorrow=tomorrow(), tomorrow_forecasts=tomorrow_forecasts,
    )

In templates/city.html, add this after the <h2> line:

<div class="box">
  <b>Tomorrow ({{ tomorrow }}), max temperature</b><br>
  {% for source, value in tomorrow_forecasts.items() %}
    {{ source }}: {{ value }} °C &nbsp;
  {% else %}
    No forecasts for tomorrow yet. Run collect.py.
  {% endfor %}
</div>

Reload the page. The browser shows a blue box:

Tomorrow (2026-10-07), max temperature
ecmwf: 22.2 °C   gfs: 24.2 °C   icon: 24.6 °C

Step 7: Table of the latest days

In app.py, add one more line to render_template(...), after the tomorrow=... line:

        rows=table.iloc[::-1].head(15).round(1).to_dict("index"),

In templates/city.html, add this after the </div> of the box (before {% endblock %}):

<h3>Latest days</h3>
<table>
  <tr><th>day</th>{% for source in sets[data_set] %}<th>{{ source }}</th>{% endfor %}<th>measured</th></tr>
  {% for day, row in rows.items() %}
  <tr><td>{{ day }}</td>
    {% for source in sets[data_set] %}<td>{{ row[source] if row[source] == row[source] else "" }}</td>{% endfor %}
    <td><b>{{ row.measured if row.measured == row.measured else "" }}</b></td></tr>
  {% endfor %}
</table>

Reload. The browser shows a table, newest day first:

day         ecmwf  gfs   icon  measured
2026-10-07  22.2   24.2  24.6
2026-10-06  23.2   24.3  25.2
2026-10-05  23.2   23.9  25.0  23.0
2026-10-04  22.5   23.2  24.2  22.4
...

Step 8: The error of each source

In app.py, add these two functions after the line app = Flask(__name__):

def mae(predicted, measured):
    """Mean absolute error: on average, how many degrees the forecast is wrong."""
    return float((predicted - measured).abs().mean())


def source_errors(table, sources):
    """Average error of each source on the days where we know the measured value."""
    known = table.dropna()
    if known.empty:
        return {}
    return {source: round(mae(known[source], known["measured"]), 2) for source in sources}

And add one line to render_template(...), after the "city.html", ... line:

        errors=source_errors(table, sources),

In templates/city.html, add this before <h3>Latest days</h3>:

<h3>Average error of each source (last 60 days)</h3>
<table>
  <tr>{% for source in errors %}<th>{{ source }}</th>{% endfor %}</tr>
  <tr>{% for value in errors.values() %}<td>{{ value }} °C</td>{% endfor %}</tr>
</table>

Reload http://127.0.0.1:5000/city/eger. The browser shows:

Average error of each source (last 60 days)
ecmwf    gfs      icon
0.68 °C  1.63 °C  1.3 °C

On the models set, ECMWF is usually the best. Is it the same in Budapest?


Step 9: The chart

In app.py, in city_page(), add this before the tomorrow_forecasts = ... line:

    chart = {
        "days": list(table.index),
        "measured": [None if v != v else v for v in table["measured"]],  # NaN -> None (empty in the chart)
        "sources": {s: [None if v != v else v for v in table[s]] for s in sources},
    }

and change the errors=... line in render_template(...) to:

        chart=chart, errors=source_errors(table, sources),

In templates/city.html, add the place of the chart after the </div> of the box:

<canvas id="chart" height="110"></canvas>

and the drawing, at the end, before {% endblock %}:

<script>
  const chart = {{ chart | tojson }};
  const datasets = [{ label: "measured", data: chart.measured, borderColor: "black", borderWidth: 3 }];
  const colors = ["#1f77b4", "#ff7f0e", "#2ca02c"];
  Object.entries(chart.sources).forEach(([name, values], i) => {
    datasets.push({ label: name, data: values, borderColor: colors[i], borderWidth: 1 });
  });
  new Chart(document.getElementById("chart"), {
    type: "line",
    data: { labels: chart.days, datasets: datasets },
    options: { spanGaps: false, pointRadius: 1 },
  });
</script>

Reload. The browser shows a line chart of 60 days: a thick black line (measured) and three thin coloured lines (the three sources), very close to the black one. The legend at the top names the lines; click a name to hide or show that line.

How it works: - In Python we prepare a dictionary with the days and one list of values per line. - JSON has no NaN, so None if v != v else v turns NaN into None (the same trick as in step 7). None becomes an empty point in the chart. - {{ chart | tojson }} writes the dictionary into the page as JSON. JavaScript can read JSON directly. - The JavaScript part: datasets is the list of lines. The first one is "measured" (black, thick). Then we add one line per source, each with its own colour. new Chart(...) draws them into the <canvas> (an empty drawing area) with the id chart.


Step 10: The main page

The main page still says Hello. Let it jump straight to Budapest.

Change the first import line in app.py to:

from flask import Flask, abort, redirect, render_template, request, url_for

and the home() function to:

@app.route("/")
def home():
    return redirect(url_for("city_page", city="budapest"))

Open http://127.0.0.1:5000. The browser jumps to /city/budapest and shows the Budapest page.

You can also add a description to the very first line of app.py, like in the complete file below. Done!


Your complete files

Compare with yours. If something does not work, copy these.

app.py

"""The web app.   python app.py   then open http://127.0.0.1:5000"""
from flask import Flask, abort, redirect, render_template, request, url_for

from config import CITIES, SETS
from db import load_table
from sources import tomorrow

app = Flask(__name__)


def mae(predicted, measured):
    """Mean absolute error: on average, how many degrees the forecast is wrong."""
    return float((predicted - measured).abs().mean())


def source_errors(table, sources):
    """Average error of each source on the days where we know the measured value."""
    known = table.dropna()
    if known.empty:
        return {}
    return {source: round(mae(known[source], known["measured"]), 2) for source in sources}


@app.route("/")
def home():
    return redirect(url_for("city_page", city="budapest"))


@app.route("/city/<city>")
def city_page(city):
    if city not in CITIES:
        abort(404)
    data_set = request.args.get("set", "models")
    if data_set not in SETS:
        abort(404)
    sources = SETS[data_set]
    table = load_table(city, sources).tail(60)

    chart = {
        "days": list(table.index),
        "measured": [None if v != v else v for v in table["measured"]],  # NaN -> None (empty in the chart)
        "sources": {s: [None if v != v else v for v in table[s]] for s in sources},
    }
    tomorrow_forecasts = table.loc[tomorrow()].drop("measured").to_dict() if tomorrow() in table.index else {}

    return render_template(
        "city.html", cities=CITIES, city=city, data_set=data_set, sets=SETS,
        chart=chart, errors=source_errors(table, sources),
        tomorrow=tomorrow(), tomorrow_forecasts=tomorrow_forecasts,
        rows=table.iloc[::-1].head(15).round(1).to_dict("index"),
    )


if __name__ == "__main__":
    app.run(debug=True)

templates/city.html

{% extends "base.html" %}
{% block content %}
<h2>{{ cities[city].name }} &ndash; {{ data_set }}</h2>

<div class="box">
  <b>Tomorrow ({{ tomorrow }}), max temperature</b><br>
  {% for source, value in tomorrow_forecasts.items() %}
    {{ source }}: {{ value }} °C &nbsp;
  {% else %}
    No forecasts for tomorrow yet. Run collect.py.
  {% endfor %}
</div>

<canvas id="chart" height="110"></canvas>

<h3>Average error of each source (last 60 days)</h3>
<table>
  <tr>{% for source in errors %}<th>{{ source }}</th>{% endfor %}</tr>
  <tr>{% for value in errors.values() %}<td>{{ value }} °C</td>{% endfor %}</tr>
</table>

<h3>Latest days</h3>
<table>
  <tr><th>day</th>{% for source in sets[data_set] %}<th>{{ source }}</th>{% endfor %}<th>measured</th></tr>
  {% for day, row in rows.items() %}
  <tr><td>{{ day }}</td>
    {% for source in sets[data_set] %}<td>{{ row[source] if row[source] == row[source] else "" }}</td>{% endfor %}
    <td><b>{{ row.measured if row.measured == row.measured else "" }}</b></td></tr>
  {% endfor %}
</table>

<script>
  const chart = {{ chart | tojson }};
  const datasets = [{ label: "measured", data: chart.measured, borderColor: "black", borderWidth: 3 }];
  const colors = ["#1f77b4", "#ff7f0e", "#2ca02c"];
  Object.entries(chart.sources).forEach(([name, values], i) => {
    datasets.push({ label: name, data: values, borderColor: colors[i], borderWidth: 1 });
  });
  new Chart(document.getElementById("chart"), {
    type: "line",
    data: { labels: chart.days, datasets: datasets },
    options: { spanGaps: false, pointRadius: 1 },
  });
</script>
{% endblock %}

templates/base.html is the same as in step 5.

Check

Extra

  1. Add a parameter ?days=30 that changes how many days are shown (default 60).
  2. Show the errors also for tmin. (You need to change load_table or write a second version of it.)
  3. Mark the best (smallest) error in the error table in green. (The CSS class best already exists.)

If something goes wrong

Problem Reason
TemplateNotFound: city.html The folder must be called exactly templates and be next to app.py.
The page is empty, no chart No internet for the Chart.js script, or a JavaScript error: press F12 in the browser, look at Console.
Address already in use Another app.py is still running. Close that terminal or press Ctrl+C there.
KeyError in the table weather.db is missing data. Run load_history.py (week 3).
NameError: name 'render_template' is not defined (or redirect, url_for, tomorrow) An import is missing at the top of app.py. Compare with the complete file above.
The change does not appear Save the file, then reload the page. If the server stopped because of an error, start it again with python app.py.
jinja2.exceptions.UndefinedError The template uses a variable that render_template(...) does not pass. Check the list of names in render_template.

← Week 3Week 5 →