12.4.4. Histogram (A)#

Nachdem Julia nun mithilfe ihres Parsers die Daten auslesen kann, macht sie sich an die Visualisierung der Histogramme.

12.4.4.1. Parser wiederverwenden#

Julia übernimmt den Parser aus dem vorherigen Schritt. Im Buch ist der Parser hier noch einmal sichtbar, damit dieses Teilkapitel für sich lesbar bleibt. In einem echten Projekt würde Julia diese Funktionen in ein eigenes Modul auslagern und wiederverwenden.

def _non_empty_lines(text):
    return [ln.strip() for ln in text.strip().splitlines() if ln.strip()]


def _ensure_comma_delimiter(lines):
    """
    Zwischenlösung: Wir unterstützen NUR Komma.
    Wenn es nach Semikolon aussieht, geben wir eine klare Fehlermeldung.
    """
    if not lines:
        raise ValueError("CSV ist leer.")

    first_line = lines[0]
    if "," not in first_line and ";" in first_line:
        raise ValueError(
            "Unerwartetes Trennzeichen ';'. Dieser Parser erwartet Komma ',' als Trennzeichen."
        )


def _parse_header(header_line):
    header = [h.strip() for h in header_line.split(",")]
    if not header or header[0] != "datetime":
        raise ValueError(
            "Ungültiger Header: erste Spalte muss 'datetime' heißen (Komma ',' als Trennzeichen)."
        )
    stations = header[1:]
    if not stations:
        raise ValueError("Header enthält keine Stationsspalten.")
    return header, stations


def _parse_no2_value(value, station, line):
    """
    Parsen eines einzelnen Messwerts.

    - Leere Felder geben wir als None zurück (d.h. Messwert fehlt).
    - Nicht-leere Felder müssen in float umwandelbar sein.
    """
    value = value.strip()
    if value == "":
        return None
    try:
        return float(value)
    except ValueError as e:
        raise ValueError(
            f"Ungültiger Messwert {value!r} für {station!r} in Zeile: {line!r}."
        ) from e


def parse_air_quality_csv_v3(csv_text):
    lines = _non_empty_lines(csv_text)
    _ensure_comma_delimiter(lines)

    header, stations = _parse_header(lines[0])
    result = {s: {"time": [], "no2": []} for s in stations}

    for line in lines[1:]:
        if "," not in line and ";" in line:
            raise ValueError(
                "Unerwartetes Trennzeichen ';' in den Datenzeilen. Dieser Parser erwartet Komma ','."
            )

        parts = [p.strip() for p in line.split(",")]
        if len(parts) > len(header):
            raise ValueError(
                f"Zu viele Spalten in Zeile: {line!r} (erwartet {len(header)}, gefunden {len(parts)})."
            )
        if len(parts) < len(header):
            parts = parts + [""] * (len(header) - len(parts))

        t = parts[0]
        for idx, station in enumerate(stations, start=1):
            parsed = _parse_no2_value(parts[idx], station, line)
            if parsed is None:
                continue
            result[station]["time"].append(t)
            result[station]["no2"].append(parsed)

    return result

12.4.4.2. Daten einlesen#

Zunächst liest Julia die Datei ein. Dafür verwendet sie urllib aus der Standardbibliothek. Es muss also nichts installiert werden (was aus Compliance-Gründen ja nicht möglich ist).

from urllib.request import urlopen

url = "https://gitlab.lrz.de/fk03ingenieurinformatik/ingenieurinformatik-buch-deploy-lrz/-/raw/master/data/air_quality_no2.csv"

with urlopen(url) as response:
    csv_text = response.read().decode("utf-8")

data = parse_air_quality_csv_v3(csv_text)

data = {k.replace("station_", "").title(): v for k, v in data.items()}

print(list(data.keys()))
print({station: list(d.keys()) for station, d in data.items()})
['Antwerp', 'Paris', 'London']
{'Antwerp': ['time', 'no2'], 'Paris': ['time', 'no2'], 'London': ['time', 'no2']}

Wenn sie etwas genauer prüfen möchte, nutzt sie zusätzlich json (ebenfalls Standardbibliothek), um eine kompakte Vorschau der Daten formatiert auszugeben.

import json

# Kompaktes JSON-Preview (erste n Werte pro Station)
def _preview_data(data, n=5):
    return {
        station: {"time": d["time"][:n], "no2": d["no2"][:n], "total": len(d["time"])}
        for station, d in data.items()
    }

print(json.dumps(_preview_data(data, n=5), indent=2, ensure_ascii=False))
{
  "Antwerp": {
    "time": [
      "2019-05-07 03:00:00",
      "2019-05-07 04:00:00",
      "2019-05-08 03:00:00",
      "2019-05-08 04:00:00",
      "2019-05-09 03:00:00"
    ],
    "no2": [
      50.5,
      45.0,
      23.0,
      20.5,
      20.0
    ],
    "total": 95
  },
  "Paris": {
    "time": [
      "2019-05-07 03:00:00",
      "2019-05-07 04:00:00",
      "2019-05-07 05:00:00",
      "2019-05-07 06:00:00",
      "2019-05-07 07:00:00"
    ],
    "no2": [
      25.0,
      27.7,
      50.4,
      61.9,
      72.4
    ],
    "total": 1004
  },
  "London": {
    "time": [
      "2019-05-07 02:00:00",
      "2019-05-07 03:00:00",
      "2019-05-07 04:00:00",
      "2019-05-07 05:00:00",
      "2019-05-07 07:00:00"
    ],
    "no2": [
      23.0,
      19.0,
      19.0,
      16.0,
      26.0
    ],
    "total": 969
  }
}

12.4.4.3. Histogramm ausgeben#

Dann schreibt Julia eine Funktion, die ein Histogramm ausgeben soll. Da sie keine externen Bibliotheken einbinden kann, bleibt nur die Konsole. Sie entscheidet sich für eine einfache Darstellung: Für jede Zählung wird ein | gezeichnet.

def _histogram_counts(values, bins=8):
    """
    Hilfsfunktion: berechnet Bin-Grenzen und Counts.

    Rückgabe:
      edges:  Liste mit Länge bins+1
      counts: Liste mit Länge bins, Summe(counts) == len(values)
    """
    if not values:
        return [], []

    vmin = min(values)
    vmax = max(values)
    if vmin == vmax:
        # Ein einziges Intervall (degenerierter Fall)
        return [vmin, vmax], [len(values)]

    step = (vmax - vmin) / bins
    edges = [vmin + i * step for i in range(bins + 1)]
    counts = [0] * bins

    for v in values:
        # letztes Intervall inklusive rechter Kante
        idx = bins - 1 if v == vmax else int((v - vmin) / step)
        counts[idx] += 1

    return edges, counts


def histogram(station_dict, bins=8):
    """
    Einfaches ASCII-Histogramm für EINE Station.

    Erwartete Struktur:
      {"time": [...], "no2": [...], ...}
    """
    values = station_dict.get("no2", [])
    if not values:
        print("(keine Werte)")
        return

    edges, counts = _histogram_counts(values, bins=bins)
    # Sonderfall: alle Werte gleich → ein einziger Balken
    if len(counts) == 1 and len(edges) == 2 and edges[0] == edges[1]:
        v = edges[0]
        print(f"{v:.1f}{v:.1f}: " + "|" * counts[0])
        return

    for i, c in enumerate(counts):
        a, b = edges[i], edges[i + 1]
        bar = "|" * c
        print(f"{a:5.1f}{b:5.1f}: {bar} ({c})")

# Unskaliert (1 Strich pro Zählung) 
for station in data:
    print(f"\nHistogramm (unskaliert): {station}")
    histogram(data[station])
Histogramm (unskaliert): Antwerp
  7.5– 15.9: ||||||||||||||||||||| (21)
 15.9– 24.2: |||||||||||||||||||||||||||||| (30)
 24.2– 32.6: |||||||||||||||||| (18)
 32.6– 41.0: ||||||||||||| (13)
 41.0– 49.4: |||||||| (8)
 49.4– 57.8: ||| (3)
 57.8– 66.1: | (1)
 66.1– 74.5: | (1)

Histogramm (unskaliert): Paris
  0.0– 12.1: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (118)
 12.1– 24.2: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (386)
 24.2– 36.4: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (258)
 36.4– 48.5: ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (139)
 48.5– 60.6: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (62)
 60.6– 72.8: ||||||||||||||||||||||||| (25)
 72.8– 84.9: |||||||||||||| (14)
 84.9– 97.0: || (2)

Histogramm (unskaliert): London
  0.0– 12.1: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (132)
 12.1– 24.2: ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (315)
 24.2– 36.4: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (422)
 36.4– 48.5: |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| (76)
 48.5– 60.6: |||||||||||||||||||| (20)
 60.6– 72.8: ||| (3)
 72.8– 84.9:  (0)
 84.9– 97.0: | (1)

12.4.4.4. Skalierung für große Datenmengen#

Für kleinere Datenmengen funktioniert die unskalierte Ausgabe gut. Bei größeren Datensätzen werden die Balken jedoch schnell so lang, dass sie über die Seitenbreite hinausragen. Julia braucht also eine skalierte Darstellung.

def histogram_scaled(station_dict, bins=8, width=50):
    """
    ASCII-Histogramm für EINE Station, aber auf eine feste Breite skaliert.

    - width: maximale Balkenlänge (Anzahl Zeichen)
    - Die Zählungen bleiben als Zahl sichtbar; der Balken ist nur eine Visualisierung.
    """
    values = station_dict.get("no2", [])
    if not values:
        print("(keine Werte)")
        return

    edges, counts = _histogram_counts(values, bins=bins)
    # Sonderfall: alle Werte gleich → ein einziger Balken (skaliert)
    if len(counts) == 1 and len(edges) == 2 and edges[0] == edges[1]:
        v = edges[0]
        bar = "|" * min(width, counts[0])
        print(f"{v:.1f}{v:.1f}: {bar} ({counts[0]})")
        return

    max_count = max(counts) or 1
    for i, c in enumerate(counts):
        a, b = edges[i], edges[i + 1]
        bar_len = round((c / max_count) * width) if c > 0 else 0
        bar_len = max(1, bar_len) if c > 0 else 0
        bar = "|" * bar_len
        print(f"{a:5.1f}{b:5.1f}: {bar} ({c})")

# Skaliert auf eine feste Breite, besser lesbar bei großen Datensätzen
for station in data:
    print(f"\nHistogramm (skaliert): {station}")
    histogram_scaled(data[station], width=50)
Histogramm (skaliert): Antwerp
  7.5– 15.9: ||||||||||||||||||||||||||||||||||| (21)
 15.9– 24.2: |||||||||||||||||||||||||||||||||||||||||||||||||| (30)
 24.2– 32.6: |||||||||||||||||||||||||||||| (18)
 32.6– 41.0: |||||||||||||||||||||| (13)
 41.0– 49.4: ||||||||||||| (8)
 49.4– 57.8: ||||| (3)
 57.8– 66.1: || (1)
 66.1– 74.5: || (1)

Histogramm (skaliert): Paris
  0.0– 12.1: ||||||||||||||| (118)
 12.1– 24.2: |||||||||||||||||||||||||||||||||||||||||||||||||| (386)
 24.2– 36.4: ||||||||||||||||||||||||||||||||| (258)
 36.4– 48.5: |||||||||||||||||| (139)
 48.5– 60.6: |||||||| (62)
 60.6– 72.8: ||| (25)
 72.8– 84.9: || (14)
 84.9– 97.0: | (2)

Histogramm (skaliert): London
  0.0– 12.1: |||||||||||||||| (132)
 12.1– 24.2: ||||||||||||||||||||||||||||||||||||| (315)
 24.2– 36.4: |||||||||||||||||||||||||||||||||||||||||||||||||| (422)
 36.4– 48.5: ||||||||| (76)
 48.5– 60.6: || (20)
 60.6– 72.8: | (3)
 72.8– 84.9:  (0)
 84.9– 97.0: | (1)

12.4.4.5. Teststrategie: Logik statt Layout#

Julia ist mit ihrer Lösung zufrieden und macht sich nun an das Testing. Auf Unit-Tests für die Darstellung (also die genaue ASCII-Ausgabe) verzichtet sie an dieser Stelle.

Glücklicherweise hat Julia den Code bereits sauber getrennt:

  • Logik: Bin-Grenzen und Counts berechnen (_histogram_counts(...))

  • Darstellung: daraus eine ASCII-Ausgabe erzeugen (histogram(...), histogram_scaled(...))

Die Logik kann sie mit Unit-Tests zuverlässig prüfen:

import ipytest
ipytest.autoconfig()
ipytest.clean()

def test_histogram_counts_sum_matches_input_length():
    values = [1.0, 1.2, 1.9, 2.0, 2.1, 2.9]
    edges, counts = _histogram_counts(values, bins=3)
    assert len(edges) == 4
    assert len(counts) == 3
    assert sum(counts) == len(values)
    assert all(c >= 0 for c in counts)


def test_histogram_counts_empty_input():
    edges, counts = _histogram_counts([], bins=5)
    assert edges == []
    assert counts == []


def test_histogram_counts_all_equal_values():
    edges, counts = _histogram_counts([2.0, 2.0, 2.0], bins=4)
    assert edges == [2.0, 2.0]
    assert counts == [3]

ipytest.run()
.
.
.
                                                                                          [100%]
3 passed in 0.02s
<ExitCode.OK: 0>