text_to_solver_program/reporting/charts.py
dschlueter 7459690bf5 refactor: Pipeline-Stufen in Ordner mit ihren Prompts
Im Wurzelverzeichnis liegt nur noch TextToModel.py (Ablauf und CLI).
Jede Stufe liegt mit Code und Prompt in einem eigenen Ordner: core,
understanding, modeling, checks, solving, reporting, data, benchmark;
Tests unter tests/, Aufgaben unter tasks/. understand_task,
text_to_model/revise_model und review_assumptions/needs_work/select_best
sind aus TextToModel.py zu ihren Prompts gewandert, inhaltlich
unveraendert. Prompt-Kennung des Benchmarks bleibt 93744636095a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:11:51 +02:00

188 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Inline-SVG-Diagramme für den HTML-Report – ohne externe Bibliotheken, aus den geprüften Zahlen der Ergebnistabellen.
Formen (siehe dataviz-Richtlinie): Magnitudenvergleich -> waagerechte Balken; Zerlegung eines Zielwerts
in Zu-/Abschläge -> Wasserfall. Farben über CSS-Rollen (--viz-*), damit Hell/Dunkel in einer Stelle umschaltet:
divergierendes Paar Blau/Rot (validiert), Gesamtwert in neutraler Textfarbe.
"""
import html
BAR_HEIGHT = 22 # <= 24px, Balken füllt das Band nicht ganz
BAR_GAP = 10 # Luft zwischen Balken (>= 2px Surface-Gap)
LABEL_WIDTH = 150
VALUE_WIDTH = 92
PADDING = 12
RADIUS = 4 # abgerundetes Datenende
def _fmt(value: float, unit: str = "") -> str:
text = f"{value:,.6g}".replace(",", " ")
return f"{text}{unit}"
def _text(x, y, content, role="ink", anchor="start", size=12, weight="normal"):
return (f'<text x="{x:.1f}" y="{y:.1f}" text-anchor="{anchor}" font-size="{size}" '
f'font-weight="{weight}" fill="var(--viz-{role})">{html.escape(str(content))}</text>')
def _bar(x, y, width, height, color_role, negative=False):
"""Balken mit abgerundetem Datenende und eckigem Ende an der Nulllinie."""
width = max(width, 1.0)
r = min(RADIUS, width / 2)
if negative: # wächst nach links -> linkes Ende gerundet
path = (f"M{x + width:.1f},{y:.1f} H{x + r:.1f} Q{x:.1f},{y:.1f} {x:.1f},{y + r:.1f} "
f"V{y + height - r:.1f} Q{x:.1f},{y + height:.1f} {x + r:.1f},{y + height:.1f} "
f"H{x + width:.1f} Z")
else:
path = (f"M{x:.1f},{y:.1f} H{x + width - r:.1f} Q{x + width:.1f},{y:.1f} {x + width:.1f},{y + r:.1f} "
f"V{y + height - r:.1f} Q{x + width:.1f},{y + height:.1f} {x + width - r:.1f},{y + height:.1f} "
f"H{x:.1f} Z")
return f'<path d="{path}" fill="var(--viz-{color_role})"/>'
def _frame(width, height, title, body) -> str:
return (f'<svg class="viz" viewBox="0 0 {width} {height:.0f}" width="100%" height="{height:.0f}" '
f'role="img" aria-label="{html.escape(title)}" xmlns="http://www.w3.org/2000/svg">'
f"{body}</svg>")
def bar_chart(labels, values, title, unit="") -> str:
"""Waagerechte Balken für Beiträge je Objekt; negative Werte links der Nulllinie (Blau/Rot)."""
if not labels:
return ""
width = 720
plot_x = LABEL_WIDTH + PADDING
plot_width = width - plot_x - VALUE_WIDTH - PADDING
height = PADDING * 2 + len(labels) * (BAR_HEIGHT + BAR_GAP)
low, high = min(0.0, min(values)), max(0.0, max(values))
span = (high - low) or 1.0
zero_x = plot_x + plot_width * (0 - low) / span
parts = [f'<line x1="{zero_x:.1f}" y1="{PADDING - 4}" x2="{zero_x:.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
for n, (label, value) in enumerate(zip(labels, values)):
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
length = plot_width * abs(value) / span
negative = value < 0
x = zero_x - length if negative else zero_x
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(value, unit)}')}</title>")
parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
value_x = x - 6 if negative else x + length + 6
parts.append(_text(value_x, y + BAR_HEIGHT * 0.72, _fmt(value, unit), "ink",
anchor="end" if negative else "start"))
return _frame(width, height, title, "".join(parts))
def waterfall_chart(labels, deltas, total_label, total_value, title, unit="") -> str:
"""Zielwert-Zerlegung: Zu- und Abschläge als Stufen, Gesamtwert als neutraler Balken ab Null."""
if not labels:
return ""
width = 720
plot_x = LABEL_WIDTH + PADDING
plot_width = width - plot_x - VALUE_WIDTH - PADDING
rows = len(labels) + 1
height = PADDING * 2 + rows * (BAR_HEIGHT + BAR_GAP)
running, levels = 0.0, []
for delta in deltas:
levels.append((running, running + delta))
running += delta
bounds = [v for pair in levels for v in pair] + [0.0, total_value]
low, high = min(bounds), max(bounds)
span = (high - low) or 1.0
scale = lambda v: plot_x + plot_width * (v - low) / span
parts = [f'<line x1="{scale(0):.1f}" y1="{PADDING - 4}" x2="{scale(0):.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
for n, (label, delta, (start, end)) in enumerate(zip(labels, deltas, levels)):
y = PADDING + n * (BAR_HEIGHT + BAR_GAP)
x0, x1 = sorted((scale(start), scale(end)))
negative = delta < 0
parts.append(f"<g><title>{html.escape(f'{label}: {_fmt(delta, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end"))
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(delta, unit), "ink"))
if n + 1 < len(labels): # Verbindungslinie zur nächsten Stufe
next_y = y + BAR_HEIGHT + BAR_GAP
parts.append(f'<line x1="{scale(end):.1f}" y1="{y + BAR_HEIGHT:.1f}" x2="{scale(end):.1f}" '
f'y2="{next_y:.1f}" stroke="var(--viz-grid)" stroke-width="1" stroke-dasharray="2 2"/>')
y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP)
x0, x1 = sorted((scale(0), scale(total_value)))
parts.append(f"<g><title>{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0))
parts.append("</g>")
parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, total_label, "ink", anchor="end", weight="600"))
parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(total_value, unit), "ink", weight="600"))
return _frame(width, height, title, "".join(parts))
# --- Graphenzeichnung (Knoten und Bögen) ---
NODE_RADIUS = 17
ARC_BOW = 34 # Wölbung je Spannweite, damit parallele Bögen sich nicht überdecken
GRAPH_PADDING = 26
def _arrow_marker(name: str, role: str) -> str:
return (f'<marker id="{name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" '
f'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--viz-{role})"/></marker>')
def graph_chart(nodes, arcs, path=(), title="Graph", unit="") -> str:
"""Knoten-Bogen-Diagramm mit hervorgehobenem Weg.
nodes: Knotennamen in Reihenfolge, arcs: (von, nach, gewicht), path: Knotenfolge des Weges.
Knoten liegen auf einer Waagerechten, Bögen wölben sich darüber (Rückwärtsbögen darunter);
die Wölbung wächst mit der Spannweite, damit sich parallele Bögen nicht überdecken.
"""
if not nodes or not arcs or len(nodes) > 14:
return ""
index = {name: i for i, name in enumerate(nodes)}
if any(a[0] not in index or a[1] not in index for a in arcs):
return ""
weg = {(a, b) for a, b in zip(path, path[1:])}
step = max(96, min(150, 820 // max(len(nodes) - 1, 1)))
breite = 2 * GRAPH_PADDING + step * (len(nodes) - 1) + 2 * NODE_RADIUS
spannen_oben = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] > index[a]]
spannen_unten = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] < index[a]]
oben = ARC_BOW * max(spannen_oben, default=0) / 2 + 30
unten = ARC_BOW * max(spannen_unten, default=0) / 2 + 30
mitte = GRAPH_PADDING + oben
hoehe = mitte + unten + GRAPH_PADDING
def x(name):
return GRAPH_PADDING + NODE_RADIUS + step * index[name]
teile = [f"<defs>{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}</defs>"]
for start, ziel, gewicht in arcs:
auf_weg = (start, ziel) in weg
rolle, marker = ("pos", "pfeil-weg") if auf_weg else ("grid", "pfeil")
x1, x2 = x(start), x(ziel)
rueckwaerts = index[ziel] < index[start]
spanne = abs(index[ziel] - index[start])
bogen = ARC_BOW * spanne * (1 if rueckwaerts else -1)
# Kreisrand statt Mittelpunkt als Anfang/Ende, damit die Pfeilspitze sichtbar bleibt
richtung = 1 if x2 > x1 else -1
x1 += richtung * NODE_RADIUS
x2 -= richtung * NODE_RADIUS
steuer_y = mitte + bogen
teile.append(f'<path d="M{x1:.1f},{mitte:.1f} Q{(x1 + x2) / 2:.1f},{steuer_y:.1f} {x2:.1f},{mitte:.1f}" '
f'fill="none" stroke="var(--viz-{rolle})" stroke-width="{3 if auf_weg else 1.5}" '
f'marker-end="url(#{marker})"/>')
teile.append(_text((x1 + x2) / 2, mitte + bogen / 2 + (20 if rueckwaerts else -10), _fmt(gewicht, unit),
role=rolle if auf_weg else "ink", anchor="middle", size=12,
weight="bold" if auf_weg else "normal"))
for name in nodes:
auf_weg = name in set(path)
teile.append(f'<circle cx="{x(name):.1f}" cy="{mitte:.1f}" r="{NODE_RADIUS}" '
f'fill="var(--viz-surface)" stroke="var(--viz-{"pos" if auf_weg else "grid"})" '
f'stroke-width="{3 if auf_weg else 1.5}"/>')
teile.append(_text(x(name), mitte + 4, name, anchor="middle", size=13,
weight="bold" if auf_weg else "normal"))
return _frame(breite, hoehe, title, "".join(teile))