Compare commits

...
4 Commits
Author SHA1 Message Date
teier 40b6afd85c fix: nginx setup 2026-10-28 15:58:01 +01:00
teier 400e0408f3 fix: port 2026-10-28 15:46:52 +01:00
teier df0950ef11 feat: add python and web implementation 2026-10-28 15:43:41 +01:00
teier fabbc6d147 feat: init project with docs, CLAUDE.md, and empty python script 2026-10-28 15:09:25 +01:00
36 changed files with 5611 additions and 1 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="~/development/modellierung-und-simulation-ue1/.venv" project-jdk-type="Python SDK" />
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$/python">
<excludeFolder url="file://$MODULE_DIR$/python/.venv" />
</content>
<orderEntry type="jdk" jdkName="~/development/modellierung-und-simulation-ue1/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PackageRequirementsSettings" />
<component name="PyDocumentationSettings" />
<component name="ReSTService" />
<component name="TestRunnerService" />
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modellierung-und-simulation-ue1.iml" filepath="$PROJECT_DIR$/.idea/modellierung-und-simulation-ue1.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
# Modellierung und Simulation UE1
This project is an exercise for the university course in modelling and simulation.
## Requirements
The requirements by the professor are saved to docs/requirements.md
My interpretation of them is available at docs/docs.md
An UML diagram is available at docs/uml.svg
## Web
The web implementation is TypeScript with React and Vite (in `web/`), served by nginx via Docker (`compose.yaml` in the root)
## Python
The python implementation is a single-file script, which can be run on the command line or a Jupyter notebook
+31 -1
View File
@@ -1,2 +1,32 @@
# modellierung-und-simulation-ue1
# Modellierung und Simulation Übung 1
Dieses Reposiotry enthält zwei Projekte:
1) Im Ordner "python" die Python-Implementierung
2) Im Ordner "web" die TypeScript-Implementierung, verfügbar unter https://sim.teier.net/ue1
## Python
```sh
cd python
uv run ue1.py --animation # bunt
uv run ue1.py --experiment alle # wind, schneise, trockenheit oder alle
un run ue1.py --wind 0.8 --animation # Einzellauf
```
## Web
Entwicklung:
```sh
cd web
npm install
npm run dev
```
Docker (nginx, http://localhost:8090/ue1):
```sh
docker compose up -d --build
BASE_PATH=/ docker compose up -d --build # anderer Pfad, hier direkt unter http://localhost:8090/
```
Der Pfad wird beim Build festgelegt (Build-Argument `BASE_PATH`, Standard `/ue1`). Der Reverse Proxy muss den Pfad
unverändert an den Container weiterreichen, also ohne das Präfix zu entfernen.
Die Web-Version verwendet einen anderen Zufallsgenerator als Python: Läufe mit demselben Seed sind reproduzierbar,
stimmen aber nicht mit den Python-Läufen überein.
+10
View File
@@ -0,0 +1,10 @@
services:
web:
build:
context: ./web
args:
BASE_PATH: ${BASE_PATH:-/ue1}
image: waldbrand-simulation
ports:
- "8090:80"
restart: unless-stopped
+32
View File
@@ -0,0 +1,32 @@
# Zellulärer Automat
Eigenschaften einer Zelle:
Brandfaktor (quasi die “Trockenheit”)
Restbrenndauer
Zustände:
Wald: Brandfaktor zwischen 0,1 und 1 (“Trockenheit”), Restbrenndauer konstant
Brennend: Brandfaktor irrelevant, Restbrenndauer sinkend
Abgebrannt, Brandfaktor 0, Restbrenndauer 0
Gelöscht, Brandfaktor 0, Restbrenndauer 0
Brandschneise (künstlich oder natürlich wie Wasser), Brandfaktor 0, Restbrenndauer 0
Die Simulation behandelt die Zustände Brandschneise, Abgebrannt und Gelöscht gleich, aber für die Visualisierung sind diese Zustände interessant
Nachbarschaft: 8 Zellen
Faktoren für Verbreitung:
Nachbarschaft: direkt mit Gewichtung 1, diagonal mit Gewichtung 0.7 (Moore)
Windrichtung: Vektor (x, y)
Windstärke: 0-1 (0=kein Wind, 1=Wind so stark, dass er Wahrscheinlichkeit in Windrichtung verdoppelt)
Windfaktor für Verbreitung:
1 + Windstärke * cos(Winkel(Vektorwind, Vektorzellen))
Entzündungswahrscheinlichkeit eine Zelle:
Distanz * Brandfaktor * Windfaktor
Rand: Der gesamte Simulationsrand ist immer eine Brandschneise
Code: https://git.teier.net/teier/modellierung-und-simulation-ue1.git
Link: https://sim.teier.net/ue1
+33
View File
@@ -0,0 +1,33 @@
Ausgangspunkt ist die Idee des zellulären Automaten, wie wir sie am Beispiel des Game of Life kennengelernt haben. Ein Raum wird in Zellen aufgeteilt. Jede Zelle besitzt einen Zustand und verändert diesen in diskreten Zeitschritten aufgrund festgelegter Regeln und der Zustände ihrer Nachbarzellen.
Entwickelt auf dieser Grundlage ein eigenes Modell für die Ausbreitung eines Waldbrandes.
Legt zunächst euer Modell fest und beschreibt:
Welche Zustände kann eine Zelle haben? Zum Beispiel Wald, brennend, abgebrannt, Wasser oder Brandschneise.
Welche Nachbarschaft wird verwendet? Vier oder acht angrenzende Zellen?
Unter welchen Bedingungen entzündet sich eine Zelle?
Was passiert mit einer brennenden Zelle im nächsten Zeitschritt?
Welche zusätzlichen Eigenschaften oder Parameter können berücksichtigt werden? zB Trockenheit, Feuchtigkeit, Wind oder Menge Löschwasser?
Wie wird der Rand des Modells behandelt?
Formuliert die Regeln so genau, dass eine andere Person eure Simulation allein anhand dieser Beschreibung implementieren könnte.
Setzt das Modell anschließend in Python um. Ihr dürft dafür KI verwenden.
Führt danach mindestens zwei unterschiedliche Simulationen durch, indem ihr gezielt einen Parameter oder eine Maßnahme verändert, zum Beispiel Trockenheit, Wind, Brandschneisen oder Löschwasser.
Vergleicht die Ergebnisse und beantwortet abschließend kurz:
Was lässt sich mit eurem Modell untersuchen? Welche Annahmen und Vereinfachungen habt ihr getroffen? Welche Aspekte eines echten Waldbrandes bildet euer Modell nicht ab? Was musstet ihr bei der Umsetzung mit KI eventuell korrigieren oder präzisieren?
Abgabe
Gebt das in einem PDF Dokument oder Colab Notebook mit dieser Struktur ab:
Modellbeschreibung
Python-Code oder einen Link zur lauffähigen Simulation
die Ergebnisse eurer beiden Versuche und
kurze Reflexion
Modus: Einzelarbeit oder Gruppenarbeit (max 2)
Frist: 9.11. (nächste Einheit)
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background: #ffffff; background-color: var(--ge-adaptive-bg, #ffffff); color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1252px" height="1562px" viewBox="0 0 1252 1562" id="ge-svg-k-QJNAvAxnYBbfuaatij" content="&lt;mxfile host=&quot;app.diagrams.net&quot; scale=&quot;1&quot; border=&quot;0&quot;&gt;&#10; &lt;diagram name=&quot;Page-1&quot; id=&quot;ybR0R8uh0gsWY412Z8hE&quot;&gt;&#10; &lt;mxGraphModel dx=&quot;1291&quot; dy=&quot;2434&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;827&quot; pageHeight=&quot;1169&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;&#10; &lt;root&gt;&#10; &lt;mxCell id=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; parent=&quot;1&quot; style=&quot;ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;&quot; value=&quot;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;30&quot; width=&quot;30&quot; x=&quot;372.5&quot; y=&quot;-90&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-2&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;html=1;verticalAlign=bottom;endArrow=open;endSize=8;strokeColor=#ff0000;rounded=0;fontSize=12;defaultLabelBackgroundColor=page;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-59&quot; value=&quot;&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;255&quot; y=&quot;280&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-7&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=70;height=70;&quot; value=&quot;Für jede Zelle im alten Zustand&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;700&quot; width=&quot;1130&quot; x=&quot;190&quot; y=&quot;330&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-25&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-24&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-54&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;EYQY27FIURCIZYL1lPWE-6&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;265&quot; y=&quot;950&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; parent=&quot;1&quot; style=&quot;rhombus;whiteSpace=wrap;html=1;shapeInside=1;&quot; value=&quot;Zellenzustand&amp;lt;div&amp;gt;ist brennend?&amp;lt;/div&amp;gt;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;120&quot; width=&quot;120&quot; x=&quot;205&quot; y=&quot;460&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-20&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=103;height=50;&quot; value=&quot;Für jeden Moore-Nachbarn im alten Zustand&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;540&quot; width=&quot;440&quot; x=&quot;837&quot; y=&quot;440&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-40&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-22&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0.25;entryDx=0;entryDy=0;&quot; target=&quot;EYQY27FIURCIZYL1lPWE-6&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;Array Line truncated

After

Width:  |  Height:  |  Size: 425 KiB

+8
View File
@@ -0,0 +1,8 @@
[project]
name = "modellierung-und-simulation-ue1"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = [
"matplotlib>=3.5",
"numpy>=1.22",
]
+414
View File
@@ -0,0 +1,414 @@
"""Waldbrand als zellulärer Automat (Modellierung und Simulation, UE1).
Kommandozeile:
python ue1.py --experiment alle
python ue1.py --experiment wind --laeufe 30 --seed 1
python ue1.py --animation
Jupyter / Colab:
%run -n ue1.py (oder: from ue1 import *)
ergebnis = simuliere(Params(wind_staerke=0.8, seed=1))
zeige_verlauf(ergebnis)
experiment_wind()
Modellregeln (Kurzfassung):
- Zustände: Wald, brennend, abgebrannt, gelöscht, Brandschneise.
Nur Wald kann sich entzünden; abgebrannt, gelöscht und Brandschneise verhalten sich gleich.
- Nachbarschaft: Moore (8 Zellen), Distanzgewicht 1 (direkt) bzw. 0.7 (diagonal).
- Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise, das Feuer kann das Gebiet
also nicht verlassen. Jede innere Zelle hat damit iåmmer 8 Nachbarn.
- Brandherd: eine zufällige Waldzelle (durch den Seed festgelegt) oder eine vorgegebene Zelle.
- Synchrones Update: alle Regeln lesen den Zustand zum Zeitpunkt t und schreiben t+1.
- Pro Zeitschritt, für jede brennende Zelle:
1. Mit Löschwahrscheinlichkeit wird sie gelöscht (und breitet sich nicht mehr aus).
2. Sonst versucht sie jeden Nachbarn, der Wald ist, zu entzünden (unabhängige Versuche):
p = 0.5 * Distanzgewicht * Brandfaktor(Nachbar) * Windfaktor
Windfaktor = 1 + Windstärke * cos(Winkel(Windvektor, Vektor brennende Zelle -> Nachbar))
3. Danach Restbrenndauer - 1; bei 0 wird sie abgebrannt.
Eine Zelle mit Restbrenndauer d breitet sich also in d Zeitschritten aus.
- Windvektor (x, y): x zeigt nach rechts (Osten), y nach oben (Norden),
der Vektor gibt die Richtung an, in die der Wind weht.
"""
import argparse
import math
from dataclasses import dataclass, field, replace
from typing import Dict, List, Optional, Sequence, Tuple
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch
# Zustände
WALD = 0
BRENNEND = 1
ABGEBRANNT = 2
GELOESCHT = 3
SCHNEISE = 4
ZUSTAND_NAMEN = ["Wald", "brennend", "abgebrannt", "gelöscht", "Brandschneise"]
ZUSTAND_FARBEN = ["#008300", "#e34948", "#3a3a38", "#86b6ef", "#d9d4c7"]
ZUSTAND_CMAP = ListedColormap(ZUSTAND_FARBEN)
# Farben für Varianten in Vergleichsplots (feste Reihenfolge)
VARIANTEN_FARBEN = ["#2a78d6", "#eb6834", "#1baf7a"]
# Moore-Nachbarschaft: (Zeilenversatz, Spaltenversatz, Distanzgewicht)
NACHBARN = [
(-1, -1, 0.7), (-1, 0, 1.0), (-1, 1, 0.7),
(0, -1, 1.0), (0, 1, 1.0),
(1, -1, 0.7), (1, 0, 1.0), (1, 1, 0.7),
]
@dataclass
class Params:
breite: int = 100
hoehe: int = 100
# Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt
schneise_anteil: float = 0.05
# Künstliche Brandschneisen als volle Zeilen bzw. Spalten
schneisen_zeilen: Sequence[int] = ()
schneisen_spalten: Sequence[int] = ()
# Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt
brandfaktor_min: float = 0.1
brandfaktor_max: float = 1.0
# Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen)
brenndauer_min: int = 2
brenndauer_max: int = 5
wind_richtung: Tuple[float, float] = (1.0, 0.0)
wind_staerke: float = 0.0
loesch_wahrscheinlichkeit: float = 0.05
# Startzelle (Zeile, Spalte), None = zufällige Waldzelle
start: Optional[Tuple[int, int]] = None
max_schritte: int = 1000
seed: Optional[int] = None
@dataclass
class Ergebnis:
params: Params
verlauf: List[np.ndarray] # Zustandsgitter je Zeitschritt, verlauf[0] = Anfangszustand
anzahl: np.ndarray # Form (Schritte + 1, 5): Anzahl Zellen je Zustand und Zeitschritt
wald_anfang: int = field(init=False)
def __post_init__(self):
self.wald_anfang = int(self.anzahl[0, WALD] + self.anzahl[0, BRENNEND])
@property
def dauer(self) -> int:
return len(self.verlauf) - 1
@property
def betroffen(self) -> int:
"""Zellen, die irgendwann gebrannt haben."""
letzte = self.anzahl[-1]
return int(letzte[BRENNEND] + letzte[ABGEBRANNT] + letzte[GELOESCHT])
@property
def betroffen_anteil(self) -> float:
"""Anteil der anfänglichen Waldfläche, die gebrannt hat."""
return self.betroffen / self.wald_anfang
def windfaktor(dr: int, dc: int, richtung: Tuple[float, float], staerke: float) -> float:
"""1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle)."""
wx, wy = richtung
norm_wind = math.hypot(wx, wy)
if staerke == 0 or norm_wind == 0:
return 1.0
# Zeilenindex wächst nach unten, y zeigt nach oben
nx, ny = dc, -dr
cos_winkel = (wx * nx + wy * ny) / (norm_wind * math.hypot(nx, ny))
return 1 + staerke * cos_winkel
def verschiebe(a: np.ndarray, dr: int, dc: int) -> np.ndarray:
"""Liefert b mit b[r, c] = a[r - dr, c - dc]; außerhalb des Gitters 0 (kein Umlauf wie bei np.roll)."""
b = np.zeros_like(a)
h, w = a.shape
b[max(dr, 0):h + min(dr, 0), max(dc, 0):w + min(dc, 0)] = \
a[max(-dr, 0):h + min(-dr, 0), max(-dc, 0):w + min(-dc, 0)]
return b
def init_gitter(params: Params, rng: np.random.Generator):
"""Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle."""
form = (params.hoehe, params.breite)
zustand = np.where(rng.random(form) < params.schneise_anteil, SCHNEISE, WALD).astype(np.int8)
if params.start is not None:
# Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start] = WALD
for zeile in params.schneisen_zeilen:
zustand[zeile, :] = SCHNEISE
for spalte in params.schneisen_spalten:
zustand[:, spalte] = SCHNEISE
# Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand[[0, -1], :] = SCHNEISE
zustand[:, [0, -1]] = SCHNEISE
if params.start is None:
# Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
# in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
waldzellen = np.argwhere(zustand == WALD)
start = tuple(waldzellen[rng.integers(len(waldzellen))])
else:
start = params.start
if zustand[start] != WALD:
raise ValueError(f"Startzelle {start} liegt auf einer künstlichen Brandschneise oder am Rand")
brandfaktor = rng.uniform(params.brandfaktor_min, params.brandfaktor_max, form)
restbrenndauer = rng.integers(params.brenndauer_min, params.brenndauer_max + 1, form)
brandfaktor[zustand == SCHNEISE] = 0
restbrenndauer[zustand == SCHNEISE] = 0
zustand[start] = BRENNEND
return zustand, brandfaktor, restbrenndauer
def schritt(zustand, brandfaktor, restbrenndauer, params: Params, rng: np.random.Generator):
"""Ein synchroner Zeitschritt; die Eingabe-Arrays werden nicht verändert."""
brennend = zustand == BRENNEND
wald = zustand == WALD
geloescht = brennend & (rng.random(zustand.shape) < params.loesch_wahrscheinlichkeit)
ausbreitend = brennend & ~geloescht
entzuendet = np.zeros_like(wald)
for dr, dc, gewicht in NACHBARN:
# Zielzelle (r, c) hat eine ausbreitende Nachbarzelle bei (r - dr, c - dc)
quelle = verschiebe(ausbreitend, dr, dc)
p = 0.5 * gewicht * brandfaktor * windfaktor(dr, dc, params.wind_richtung, params.wind_staerke)
entzuendet |= wald & quelle & (rng.random(zustand.shape) < p)
neu_zustand = zustand.copy()
neu_brandfaktor = brandfaktor.copy()
neu_restbrenndauer = restbrenndauer.copy()
neu_restbrenndauer[ausbreitend] -= 1
ausgebrannt = ausbreitend & (neu_restbrenndauer == 0)
neu_zustand[ausgebrannt] = ABGEBRANNT
neu_zustand[geloescht] = GELOESCHT
neu_restbrenndauer[geloescht] = 0
neu_brandfaktor[ausgebrannt | geloescht] = 0
neu_zustand[entzuendet] = BRENNEND
return neu_zustand, neu_brandfaktor, neu_restbrenndauer
def zaehle(zustand: np.ndarray) -> np.ndarray:
return np.bincount(zustand.ravel(), minlength=len(ZUSTAND_NAMEN))
def simuliere(params: Params) -> Ergebnis:
"""Simuliert, bis keine Zelle mehr brennt oder max_schritte erreicht ist."""
rng = np.random.default_rng(params.seed)
zustand, brandfaktor, restbrenndauer = init_gitter(params, rng)
verlauf = [zustand]
anzahl = [zaehle(zustand)]
while anzahl[-1][BRENNEND] > 0 and len(verlauf) <= params.max_schritte:
zustand, brandfaktor, restbrenndauer = schritt(zustand, brandfaktor, restbrenndauer, params, rng)
verlauf.append(zustand)
anzahl.append(zaehle(zustand))
return Ergebnis(params, verlauf, np.array(anzahl))
# ---------------------------------------------------------------------------
# Visualisierung
# ---------------------------------------------------------------------------
def zeige_gitter(zustand: np.ndarray, ax=None, titel: str = ""):
if ax is None:
_, ax = plt.subplots(figsize=(5, 5))
ax.imshow(zustand, cmap=ZUSTAND_CMAP, vmin=0, vmax=len(ZUSTAND_NAMEN) - 1, interpolation="nearest")
ax.set_title(titel, fontsize=10)
ax.set_xticks([])
ax.set_yticks([])
return ax
def zustand_legende(fig):
griffe = [Patch(color=farbe, label=name) for name, farbe in zip(ZUSTAND_NAMEN, ZUSTAND_FARBEN)]
fig.legend(handles=griffe, loc="lower center", ncol=len(griffe), frameon=False, fontsize=9)
def zeichne_kurven(ax, ergebnis: Ergebnis, farbe: str, name: Optional[str] = None):
"""Betroffene Fläche über die Zeit; ohne Name zusätzlich die brennenden Zellen (gestrichelt)."""
a = ergebnis.anzahl
betroffen = (a[:, BRENNEND] + a[:, ABGEBRANNT] + a[:, GELOESCHT]) / ergebnis.wald_anfang * 100
ax.plot(betroffen, color=farbe, linewidth=2, label=name or "betroffen gesamt")
if name is None:
brennend = a[:, BRENNEND] / ergebnis.wald_anfang * 100
ax.plot(brennend, color=farbe, linewidth=2, linestyle="--", label="brennend")
def kurven_achse(ax):
ax.set_xlabel("Zeitschritt")
ax.set_ylabel("% der Waldfläche")
ax.set_ylim(0, 100)
ax.grid(alpha=0.3)
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False, fontsize=9)
def zeige_verlauf(ergebnis: Ergebnis, anzahl_bilder: int = 5):
"""Momentaufnahmen des Gitters und Zeitverlauf eines einzelnen Laufs."""
zeitpunkte = np.linspace(0, ergebnis.dauer, anzahl_bilder).astype(int)
fig = plt.figure(figsize=(3 * anzahl_bilder, 6.5))
raster = fig.add_gridspec(2, anzahl_bilder, height_ratios=[1, 0.8])
for i, t in enumerate(zeitpunkte):
zeige_gitter(ergebnis.verlauf[t], fig.add_subplot(raster[0, i]), f"t = {t}")
ax = fig.add_subplot(raster[1, :])
zeichne_kurven(ax, ergebnis, VARIANTEN_FARBEN[0])
kurven_achse(ax)
fig.suptitle(f"Betroffen: {ergebnis.betroffen_anteil:.0%} der Waldfläche, Dauer: {ergebnis.dauer} Schritte")
zustand_legende(fig)
fig.tight_layout(rect=(0, 0.05, 1, 1))
return fig
def zeige_vergleich(ergebnisse: Dict[str, Ergebnis]):
"""Endzustand jeder Variante nebeneinander plus gemeinsamer Zeitverlauf."""
n = len(ergebnisse)
fig = plt.figure(figsize=(4 * n + 5, 4.8))
raster = fig.add_gridspec(1, n + 1, width_ratios=[1] * n + [1.4])
ax_kurven = fig.add_subplot(raster[0, n])
for i, (name, ergebnis) in enumerate(ergebnisse.items()):
titel = f"{name}\nbetroffen {ergebnis.betroffen_anteil:.0%}, {ergebnis.dauer} Schritte"
zeige_gitter(ergebnis.verlauf[-1], fig.add_subplot(raster[0, i]), titel)
zeichne_kurven(ax_kurven, ergebnis, VARIANTEN_FARBEN[i % len(VARIANTEN_FARBEN)], name)
kurven_achse(ax_kurven)
zustand_legende(fig)
fig.tight_layout(rect=(0, 0.07, 1, 1))
return fig
def animiere(ergebnis: Ergebnis, intervall_ms: int = 80):
"""Animation eines Laufs. In Jupyter: from IPython.display import HTML; HTML(anim.to_jshtml())"""
fig, ax = plt.subplots(figsize=(6, 6.4))
bild = ax.imshow(ergebnis.verlauf[0], cmap=ZUSTAND_CMAP, vmin=0, vmax=len(ZUSTAND_NAMEN) - 1,
interpolation="nearest")
ax.set_xticks([])
ax.set_yticks([])
zustand_legende(fig)
def update(t):
bild.set_data(ergebnis.verlauf[t])
ax.set_title(f"t = {t}")
return (bild,)
anim = FuncAnimation(fig, update, frames=len(ergebnis.verlauf), interval=intervall_ms)
return anim
# ---------------------------------------------------------------------------
# Experimente
# ---------------------------------------------------------------------------
def vergleiche(varianten: Dict[str, Params], laeufe: int = 20, seed: int = 0, zeigen: bool = True):
"""Simuliert jede Variante mit denselben Seeds und gibt Mittelwerte aus.
Gleiche Seeds bedeuten: alle Varianten starten mit demselben Wald und Brandherd (gleiche Brandfaktoren,
Brenndauern und natürlichen Schneisen), soweit die Variante die Initialisierung nicht selbst ändert.
"""
statistik = {}
beispiele = {}
for name, params in varianten.items():
ergebnisse = [simuliere(replace(params, seed=seed + i)) for i in range(laeufe)]
anteile = np.array([e.betroffen_anteil for e in ergebnisse])
dauern = np.array([e.dauer for e in ergebnisse])
statistik[name] = {
"betroffen_mittel": anteile.mean(),
"betroffen_std": anteile.std(),
"dauer_mittel": dauern.mean(),
"dauer_std": dauern.std(),
"grossbrand_anteil": (anteile > 0.5).mean(),
}
# Beispiel für die Grafik: der Lauf mit dem Median der betroffenen Fläche
beispiele[name] = ergebnisse[int(np.argsort(anteile)[len(anteile) // 2])]
print(f"{'Variante':<28}{'betroffen (Mittel ± Std)':>26}{'Dauer (Mittel ± Std)':>24}{'Läufe > 50 %':>14}")
for name, s in statistik.items():
print(f"{name:<28}{s['betroffen_mittel']:>17.1%} ± {s['betroffen_std']:>5.1%}"
f"{s['dauer_mittel']:>16.1f} ± {s['dauer_std']:>5.1f}{s['grossbrand_anteil']:>14.0%}")
print()
if zeigen:
zeige_vergleich(beispiele)
return statistik, beispiele
def experiment_wind(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Wind: Windstärke 0 vs. 0.8 (Wind nach Osten)")
basis = Params()
return vergleiche({
"ohne Wind": basis,
"Wind 0.8 nach Osten": replace(basis, wind_richtung=(1, 0), wind_staerke=0.8),
}, laeufe, seed, zeigen)
def experiment_schneise(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Brandschneise: Wind nach Osten, ohne vs. mit Schneise östlich des Brandherds")
# Fester Brandherd westlich der Schneise, damit der Vergleich nicht vom zufälligen Startpunkt abhängt
basis = Params(wind_richtung=(1, 0), wind_staerke=0.8, start=(50, 50))
# Eine durchgehende gerade Schneise ist in der Moore-Nachbarschaft schon mit 1 Zelle Breite
# unüberwindbar, die Breite spielt in diesem Modell also keine Rolle
return vergleiche({
"ohne Schneise": basis,
"Schneise in Spalte 65": replace(basis, schneisen_spalten=(65,)),
}, laeufe, seed, zeigen)
def experiment_trockenheit(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Trockenheit: Brandfaktor feucht (0.1-0.5) vs. normal (0.1-1.0) vs. trocken (0.5-1.0)")
basis = Params()
return vergleiche({
"feucht (0.1-0.5)": replace(basis, brandfaktor_min=0.1, brandfaktor_max=0.5),
"normal (0.1-1.0)": basis,
"trocken (0.5-1.0)": replace(basis, brandfaktor_min=0.5, brandfaktor_max=1.0),
}, laeufe, seed, zeigen)
EXPERIMENTE = {
"wind": experiment_wind,
"schneise": experiment_schneise,
"trockenheit": experiment_trockenheit,
}
def main():
parser = argparse.ArgumentParser(description="Waldbrand als zellulärer Automat")
parser.add_argument("--experiment", choices=[*EXPERIMENTE, "alle"],
help="Experiment ausführen (ohne Angabe: einzelner Lauf mit Standardparametern)")
parser.add_argument("--laeufe", type=int, default=20, help="Läufe je Variante")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--wind", type=float, default=0.0, help="Windstärke für den Einzellauf")
parser.add_argument("--animation", action="store_true", help="Einzellauf animieren")
parser.add_argument("--speichern", metavar="PRAEFIX", help="Grafiken als PRAEFIX_<n>.png speichern statt anzeigen")
args = parser.parse_args()
anim = None
if args.experiment:
namen = list(EXPERIMENTE) if args.experiment == "alle" else [args.experiment]
for name in namen:
EXPERIMENTE[name](args.laeufe, args.seed)
else:
ergebnis = simuliere(Params(wind_staerke=args.wind, seed=args.seed))
if args.animation:
anim = animiere(ergebnis) # Referenz halten, sonst stoppt die Animation
else:
zeige_verlauf(ergebnis)
if args.speichern:
for i in plt.get_fignums():
plt.figure(i).savefig(f"{args.speichern}_{i}.png", dpi=150)
else:
plt.show()
return anim
if __name__ == "__main__":
main()
+1701
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
Dockerfile
.dockerignore
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+17
View File
@@ -0,0 +1,17 @@
# Pfad, unter dem die App ausgeliefert wird, z.B. /ue1
ARG BASE_PATH=/
FROM node:26-trixie-slim AS build
ARG BASE_PATH
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN BASE_PATH="$BASE_PATH" npm run build
FROM nginx:mainline-trixie
ARG BASE_PATH
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Willkommensseite des Images entfernen
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html/${BASE_PATH}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Waldbrand-Simulation als zellulärer Automat" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
/>
<title>Waldbrand-Simulation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
# Weiterleitung von /ue1 auf /ue1/ relativ, damit sie hinter einem Reverse Proxy stimmt
absolute_redirect off;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
# Dateinamen in assets/ enthalten einen Hash und ändern sich mit jedem Build
location ~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
+1694
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "waldbrand-simulation",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.3.1",
"react-dom": "^19.3.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import Experimente from "./komponenten/Experimente";
import Simulation from "./komponenten/Simulation";
type Ansicht = "simulation" | "experimente";
export default function App() {
const [ansicht, setAnsicht] = useState<Ansicht>("simulation");
return (
<>
<header className="kopf">
<div>
<h1>Waldbrand-Simulation</h1>
<p>Zellulärer Automat – Modellierung und Simulation, Übung 1</p>
</div>
<nav className="reiter" role="tablist">
{(
[
["simulation", "Simulation"],
["experimente", "Experimente"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="tab"
aria-selected={ansicht === wert}
className={ansicht === wert ? "reiter__tab aktiv" : "reiter__tab"}
onClick={() => setAnsicht(wert)}
>
{name}
</button>
))}
</nav>
</header>
{/* Beide Ansichten bleiben eingehängt, damit ein laufender Brand beim Wechsel erhalten bleibt */}
<main hidden={ansicht !== "simulation"}>
<Simulation />
</main>
<main hidden={ansicht !== "experimente"}>
<Experimente />
</main>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { simuliere, STANDARD_PARAMS, type Ergebnis, type Params } from "./modell";
export interface Experiment {
titel: string;
beschreibung: string;
varianten: Record<string, Params>;
}
const MITTE = [STANDARD_PARAMS.hoehe / 2, STANDARD_PARAMS.breite / 2] as const;
export const EXPERIMENTE = {
wind: {
titel: "Wind",
beschreibung: "Windstärke 0 vs. 0.8, Wind nach Osten.",
varianten: {
"ohne Wind": STANDARD_PARAMS,
"Wind 0.8 nach Osten": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8 },
},
},
schneise: {
titel: "Brandschneise",
beschreibung:
"Wind 0.8 nach Osten, ohne vs. mit durchgehender Schneise östlich des Brandherds. " +
"Der Brandherd liegt fest in der Mitte, damit der Vergleich nicht vom zufälligen Startpunkt abhängt.",
varianten: {
"ohne Schneise": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE },
"Schneise in Spalte 65": {
...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE, schneisenSpalten: [65],
},
},
},
trockenheit: {
titel: "Trockenheit",
beschreibung: "Brandfaktor feucht (0.1–0.5) vs. normal (0.1–1.0) vs. trocken (0.5–1.0).",
varianten: {
"feucht (0.1–0.5)": { ...STANDARD_PARAMS, brandfaktorMin: 0.1, brandfaktorMax: 0.5 },
"normal (0.1–1.0)": STANDARD_PARAMS,
"trocken (0.5–1.0)": { ...STANDARD_PARAMS, brandfaktorMin: 0.5, brandfaktorMax: 1.0 },
},
},
} satisfies Record<string, Experiment>;
export type ExperimentName = keyof typeof EXPERIMENTE;
export interface Statistik {
name: string;
betroffenMittel: number;
betroffenStd: number;
dauerMittel: number;
dauerStd: number;
grossbrandAnteil: number;
/** Lauf mit dem Median der betroffenen Fläche, als Beispiel für die Grafik */
beispiel: Ergebnis;
}
function mittelUndStd(werte: number[]): [number, number] {
const mittel = werte.reduce((a, b) => a + b, 0) / werte.length;
const varianz = werte.reduce((a, b) => a + (b - mittel) ** 2, 0) / werte.length;
return [mittel, Math.sqrt(varianz)];
}
/**
* Simuliert jede Variante mit denselben Seeds. Gleiche Seeds bedeuten: alle Varianten starten mit
* demselben Wald und Brandherd, soweit die Variante die Initialisierung nicht selbst ändert.
*/
export function vergleiche(
varianten: Record<string, Params>,
laeufe: number,
seed: number,
fortschritt?: (fertig: number, gesamt: number) => void,
): Statistik[] {
const gesamt = Object.keys(varianten).length * laeufe;
let fertig = 0;
return Object.entries(varianten).map(([name, params]) => {
const ergebnisse: Ergebnis[] = [];
for (let i = 0; i < laeufe; i++) {
ergebnisse.push(simuliere({ ...params, seed: seed + i }));
fortschritt?.(++fertig, gesamt);
}
const anteile = ergebnisse.map((e) => e.betroffenAnteil);
const [betroffenMittel, betroffenStd] = mittelUndStd(anteile);
const [dauerMittel, dauerStd] = mittelUndStd(ergebnisse.map((e) => e.dauer));
const sortiert = [...ergebnisse].sort((a, b) => a.betroffenAnteil - b.betroffenAnteil);
return {
name,
betroffenMittel,
betroffenStd,
dauerMittel,
dauerStd,
grossbrandAnteil: anteile.filter((a) => a > 0.5).length / laeufe,
beispiel: sortiert[Math.floor(laeufe / 2)],
};
});
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { betroffen, waldflaeche } from "../modell";
import { EXPERIMENTE, type ExperimentName, type Statistik } from "../experimente";
import type { Auftrag, Nachricht } from "../worker";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
// Feste Reihenfolge: Variante i bekommt immer Farbe i
const VARIANTEN_FARBEN = ["#2a78d6", "#eb6834", "#1baf7a"];
const prozent = (wert: number) => `${(wert * 100).toFixed(1)} %`;
/** Betroffene Fläche je Variante, zusammengeführt nach Zeitschritt */
function vergleichsdaten(statistik: Statistik[]): Datenpunkt[] {
const laengste = Math.max(...statistik.map((s) => s.beispiel.dauer));
return Array.from({ length: laengste + 1 }, (_, t) => {
const punkt: Datenpunkt = { t };
statistik.forEach((s, i) => {
const anzahl = s.beispiel.anzahl[t];
if (anzahl) punkt[`v${i}`] = (betroffen(anzahl) / waldflaeche(anzahl)) * 100;
});
return punkt;
});
}
interface Auswertung {
experiment: ExperimentName;
laeufe: number;
statistik: Statistik[];
}
export default function Experimente() {
const [experiment, setExperiment] = useState<ExperimentName>("wind");
const [laeufe, setLaeufe] = useState(20);
const [seed, setSeed] = useState(0);
const [fortschritt, setFortschritt] = useState<number | null>(null);
const [auswertung, setAuswertung] = useState<Auswertung | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => () => workerRef.current?.terminate(), []);
function starten() {
workerRef.current?.terminate();
const worker = new Worker(new URL("../worker.ts", import.meta.url), { type: "module" });
workerRef.current = worker;
setFortschritt(0);
setFehler(null);
worker.onmessage = ({ data }: MessageEvent<Nachricht>) => {
if (data.typ === "fortschritt") {
setFortschritt(data.fertig / data.gesamt);
} else {
setAuswertung({ experiment, laeufe, statistik: data.statistik });
setFortschritt(null);
worker.terminate();
}
};
worker.onerror = (e) => {
setFehler(e.message);
setFortschritt(null);
};
worker.postMessage({ experiment, laeufe, seed } satisfies Auftrag);
}
const serien: Serie[] =
auswertung?.statistik.map((s, i) => ({ schluessel: `v${i}`, name: s.name, farbe: VARIANTEN_FARBEN[i] })) ?? [];
return (
<div className="experimente">
<section className="karte">
<h2>Experiment</h2>
<div className="experiment-wahl" role="radiogroup" aria-label="Experiment">
{(Object.keys(EXPERIMENTE) as ExperimentName[]).map((name) => (
<button
key={name}
role="radio"
aria-checked={experiment === name}
className={experiment === name ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setExperiment(name)}
>
{EXPERIMENTE[name].titel}
</button>
))}
</div>
<p>{EXPERIMENTE[experiment].beschreibung}</p>
<p className="hinweis">
Jede Variante läuft mit denselben Seeds, startet also mit demselben Wald und Brandherd. Alle übrigen
Parameter haben ihre Standardwerte (Löschwahrscheinlichkeit 5 %, Brenndauer 2–5, 5 % natürliche Schneisen).
</p>
<div className="steuerung">
<label className="feld feld--inline">
Läufe je Variante
<input
type="number"
min={1}
max={200}
value={laeufe}
onChange={(e) => setLaeufe(Math.min(200, Math.max(1, Math.floor(Number(e.target.value)))))}
/>
</label>
<label className="feld feld--inline">
Seed
<input
type="number"
min={0}
value={seed}
onChange={(e) => setSeed(Math.max(0, Math.floor(Number(e.target.value))))}
/>
</label>
<button className="knopf knopf--primaer" onClick={starten} disabled={fortschritt !== null}>
{fortschritt !== null ? "Läuft …" : "Starten"}
</button>
</div>
{fortschritt !== null && <progress value={fortschritt} max={1} />}
{fehler && <p className="fehler">Fehler: {fehler}</p>}
</section>
{auswertung && (
<>
<section className="karte">
<h2>
Ergebnis: {EXPERIMENTE[auswertung.experiment].titel} ({auswertung.laeufe} Läufe je Variante)
</h2>
<div className="tabelle-rahmen">
<table>
<thead>
<tr>
<th>Variante</th>
<th>betroffen (Mittel ± Std)</th>
<th>Dauer in Schritten (Mittel ± Std)</th>
<th>Läufe &gt; 50 % betroffen</th>
</tr>
</thead>
<tbody>
{auswertung.statistik.map((s) => (
<tr key={s.name}>
<td>{s.name}</td>
<td>
{prozent(s.betroffenMittel)} ± {prozent(s.betroffenStd)}
</td>
<td>
{s.dauerMittel.toFixed(1)} ± {s.dauerStd.toFixed(1)}
</td>
<td>{Math.round(s.grossbrandAnteil * 100)} %</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="karte">
<h2>Typischer Lauf je Variante</h2>
<p className="hinweis">Endzustand des Laufs mit dem Median der betroffenen Fläche.</p>
<div className="beispiele">
{auswertung.statistik.map((s) => (
<figure key={s.name}>
<Gitter
breite={s.beispiel.breite}
hoehe={s.beispiel.hoehe}
zustand={s.beispiel.endzustand}
beschriftung={`Endzustand ${s.name}`}
/>
<figcaption>
<strong>{s.name}</strong>
<br />
betroffen {prozent(s.beispiel.betroffenAnteil)}, {s.beispiel.dauer} Schritte
</figcaption>
</figure>
))}
</div>
<Legende />
<h3>Betroffene Fläche über die Zeit</h3>
<Verlaufsdiagramm daten={vergleichsdaten(auswertung.statistik)} serien={serien} />
</section>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, type PointerEvent } from "react";
import { ZUSTAENDE, type Zelle } from "../modell";
// Zustandsfarben als RGB, einmal vorberechnet
const FARBEN_RGB = ZUSTAENDE.map(({ farbe }) => [1, 3, 5].map((i) => parseInt(farbe.slice(i, i + 2), 16)));
interface Props {
breite: number;
hoehe: number;
zustand: Uint8Array;
/** Erhöhen, um neu zu zeichnen, wenn das Gitter direkt verändert wurde */
version?: number;
/** Wird beim Klicken und Ziehen mit der Zelle unter dem Zeiger aufgerufen */
onZelle?: (zelle: Zelle) => void;
beschriftung: string;
}
export default function Gitter({ breite, hoehe, zustand, version, onZelle, beschriftung }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const maltRef = useRef(false);
useEffect(() => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const bild = ctx.createImageData(breite, hoehe);
for (let i = 0; i < zustand.length; i++) {
const [r, g, b] = FARBEN_RGB[zustand[i]];
bild.data[4 * i] = r;
bild.data[4 * i + 1] = g;
bild.data[4 * i + 2] = b;
bild.data[4 * i + 3] = 255;
}
ctx.putImageData(bild, 0, 0);
}, [breite, hoehe, zustand, version]);
function zelleUnterZeiger(e: PointerEvent<HTMLCanvasElement>): Zelle {
const rechteck = e.currentTarget.getBoundingClientRect();
return [
Math.floor(((e.clientY - rechteck.top) / rechteck.height) * hoehe),
Math.floor(((e.clientX - rechteck.left) / rechteck.width) * breite),
];
}
return (
<canvas
ref={canvasRef}
className={onZelle ? "gitter gitter--interaktiv" : "gitter"}
width={breite}
height={hoehe}
style={{ aspectRatio: `${breite} / ${hoehe}` }}
role="img"
aria-label={beschriftung}
onPointerDown={(e) => {
if (!onZelle) return;
maltRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
onZelle(zelleUnterZeiger(e));
}}
onPointerMove={(e) => {
if (onZelle && maltRef.current) onZelle(zelleUnterZeiger(e));
}}
onPointerUp={() => (maltRef.current = false)}
onPointerCancel={() => (maltRef.current = false)}
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ZUSTAENDE } from "../modell";
export default function Legende() {
return (
<ul className="legende">
{ZUSTAENDE.map(({ name, farbe }) => (
<li key={name}>
<span className="legende__farbe" style={{ background: farbe }} />
{name}
</li>
))}
</ul>
);
}
+28
View File
@@ -0,0 +1,28 @@
interface Props {
name: string;
wert: number;
min: number;
max: number;
schritt: number;
format?: (wert: number) => string;
onChange: (wert: number) => void;
}
export default function Regler({ name, wert, min, max, schritt, format = String, onChange }: Props) {
return (
<label className="regler">
<span className="regler__kopf">
<span>{name}</span>
<output>{format(wert)}</output>
</span>
<input
type="range"
min={min}
max={max}
step={schritt}
value={wert}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
BRENNEND,
betroffen,
entzuende,
initGitter,
schritt,
setzeSchneise,
STANDARD_PARAMS,
waldflaeche,
zaehle,
zufallsgenerator,
type Gitter as GitterDaten,
type Params,
type Zelle,
type Zufall,
} from "../modell";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Regler from "./Regler";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
const WINDRICHTUNGEN: { name: string; vektor: readonly [number, number] }[] = [
{ name: "→ nach Osten", vektor: [1, 0] },
{ name: "↗ nach Nordosten", vektor: [1, 1] },
{ name: "↑ nach Norden", vektor: [0, 1] },
{ name: "↖ nach Nordwesten", vektor: [-1, 1] },
{ name: "← nach Westen", vektor: [-1, 0] },
{ name: "↙ nach Südwesten", vektor: [-1, -1] },
{ name: "↓ nach Süden", vektor: [0, -1] },
{ name: "↘ nach Südosten", vektor: [1, -1] },
];
const GROESSEN = [50, 100, 150, 200];
const SERIEN: Serie[] = [
{ schluessel: "betroffen", name: "betroffen gesamt", farbe: "#2a78d6" },
{ schluessel: "brennend", name: "brennend", farbe: "#eb6834" },
];
type Werkzeug = "schneise" | "feuer";
interface Lauf {
gitter: GitterDaten;
zufall: Zufall;
t: number;
}
function messpunkt(t: number, gitter: GitterDaten): Datenpunkt {
const anzahl = zaehle(gitter.zustand);
const flaeche = waldflaeche(anzahl);
return {
t,
betroffen: (betroffen(anzahl) / flaeche) * 100,
brennend: (anzahl[BRENNEND] / flaeche) * 100,
};
}
const prozent = (wert: number) => `${Math.round(wert * 100)} %`;
export default function Simulation() {
const [params, setParams] = useState<Params>({ ...STANDARD_PARAMS, seed: 1 });
const [laeuft, setLaeuft] = useState(false);
const [tempo, setTempo] = useState(20);
const [werkzeug, setWerkzeug] = useState<Werkzeug>("schneise");
const [verlauf, setVerlauf] = useState<Datenpunkt[]>([]);
const [version, setVersion] = useState(0);
const laufRef = useRef<Lauf | null>(null);
// Wind und Löschwahrscheinlichkeit wirken sofort, auch während die Simulation läuft
const paramsRef = useRef(params);
paramsRef.current = params;
function aendere<K extends keyof Params>(schluessel: K, wert: Params[K]) {
setParams((p) => ({ ...p, [schluessel]: wert }));
}
// Änderungen an Parametern, die die Initialisierung betreffen, erzeugen einen neuen Wald
const { breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed } = params;
const neuStarten = useCallback(() => {
const zufall = zufallsgenerator(paramsRef.current.seed);
const gitter = initGitter(paramsRef.current, zufall);
laufRef.current = { gitter, zufall, t: 0 };
setVerlauf([messpunkt(0, gitter)]);
setVersion((v) => v + 1);
setLaeuft(false);
}, []);
useEffect(neuStarten, [
neuStarten, breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed,
]);
const einSchritt = useCallback(() => {
const lauf = laufRef.current;
if (!lauf) return;
lauf.gitter = schritt(lauf.gitter, paramsRef.current, lauf.zufall);
lauf.t += 1;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v, punkt]);
setVersion((v) => v + 1);
if (punkt.brennend === 0) setLaeuft(false);
}, []);
useEffect(() => {
if (!laeuft) return;
const id = setInterval(einSchritt, 1000 / tempo);
return () => clearInterval(id);
}, [laeuft, tempo, einSchritt]);
const onZelle = useCallback(
(zelle: Zelle) => {
const lauf = laufRef.current;
if (!lauf) return;
const geaendert = werkzeug === "schneise" ? setzeSchneise(lauf.gitter, zelle) : entzuende(lauf.gitter, zelle);
if (!geaendert) return;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v.slice(0, -1), punkt]);
setVersion((v) => v + 1);
},
[werkzeug],
);
const lauf = laufRef.current;
const aktuell = verlauf[verlauf.length - 1];
const brennt = aktuell !== undefined && aktuell.brennend > 0;
const brennendeZellen = lauf ? zaehle(lauf.gitter.zustand)[BRENNEND] : 0;
const status = laeuft ? "läuft" : !brennt ? "beendet" : lauf?.t === 0 ? "bereit" : "pausiert";
const windIndex = WINDRICHTUNGEN.findIndex(
({ vektor }) => vektor[0] === params.windRichtung[0] && vektor[1] === params.windRichtung[1],
);
return (
<div className="simulation">
<section className="karte simulation__gitter">
{lauf && (
<Gitter
breite={lauf.gitter.breite}
hoehe={lauf.gitter.hoehe}
zustand={lauf.gitter.zustand}
version={version}
onZelle={onZelle}
beschriftung={`Waldgitter nach ${lauf.t} Zeitschritten`}
/>
)}
<Legende />
<div className="steuerung">
<button className="knopf knopf--primaer" onClick={() => setLaeuft(!laeuft)} disabled={!brennt}>
{laeuft ? "Pause" : "Start"}
</button>
<button className="knopf" onClick={einSchritt} disabled={laeuft || !brennt}>
Schritt
</button>
<button className="knopf" onClick={neuStarten}>
Neu
</button>
<div className="werkzeug" role="radiogroup" aria-label="Werkzeug">
{(
[
["schneise", "Schneise zeichnen"],
["feuer", "Feuer legen"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="radio"
aria-checked={werkzeug === wert}
className={werkzeug === wert ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setWerkzeug(wert)}
>
{name}
</button>
))}
</div>
</div>
<dl className="kennzahlen">
<div>
<dt>Zeitschritt</dt>
<dd>{lauf?.t ?? 0}</dd>
</div>
<div>
<dt>betroffen</dt>
<dd>{aktuell ? aktuell.betroffen.toFixed(1) : 0} %</dd>
</div>
<div>
<dt>brennend</dt>
<dd>{brennendeZellen === 1 ? "1 Zelle" : `${brennendeZellen} Zellen`}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{status}</dd>
</div>
</dl>
<p className="hinweis">
Auf dem Gitter klicken oder ziehen, um Brandschneisen zu zeichnen oder neue Brandherde zu legen.
</p>
</section>
<section className="karte simulation__parameter">
<h2>Parameter</h2>
<div className="feld">
<label htmlFor="groesse">Gittergröße</label>
<select
id="groesse"
value={params.breite}
onChange={(e) => setParams((p) => ({ ...p, breite: Number(e.target.value), hoehe: Number(e.target.value) }))}
>
{GROESSEN.map((g) => (
<option key={g} value={g}>
{g} × {g}
</option>
))}
</select>
</div>
<div className="feld">
<label htmlFor="seed">Seed</label>
<div className="feld__zeile">
<input
id="seed"
type="number"
min={0}
value={params.seed}
onChange={(e) => aendere("seed", Math.max(0, Math.floor(Number(e.target.value))))}
/>
<button className="knopf" onClick={() => aendere("seed", Math.floor(Math.random() * 1_000_000))}>
Zufällig
</button>
</div>
</div>
<h3>Wald</h3>
<Regler
name="Natürliche Schneisen"
wert={params.schneiseAnteil}
min={0}
max={0.4}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("schneiseAnteil", w)}
/>
<Regler
name="Brandfaktor min"
wert={params.brandfaktorMin}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMin: w, brandfaktorMax: Math.max(w, p.brandfaktorMax) }))}
/>
<Regler
name="Brandfaktor max"
wert={params.brandfaktorMax}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMax: w, brandfaktorMin: Math.min(w, p.brandfaktorMin) }))}
/>
<Regler
name="Brenndauer min"
wert={params.brenndauerMin}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMin: w, brenndauerMax: Math.max(w, p.brenndauerMax) }))}
/>
<Regler
name="Brenndauer max"
wert={params.brenndauerMax}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMax: w, brenndauerMin: Math.min(w, p.brenndauerMin) }))}
/>
<h3>Während der Simulation änderbar</h3>
<div className="feld">
<label htmlFor="windrichtung">Windrichtung</label>
<select
id="windrichtung"
value={windIndex}
onChange={(e) => aendere("windRichtung", WINDRICHTUNGEN[Number(e.target.value)].vektor)}
>
{WINDRICHTUNGEN.map(({ name }, i) => (
<option key={name} value={i}>
{name}
</option>
))}
</select>
</div>
<Regler
name="Windstärke"
wert={params.windStaerke}
min={0}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => aendere("windStaerke", w)}
/>
<Regler
name="Löschwahrscheinlichkeit"
wert={params.loeschWahrscheinlichkeit}
min={0}
max={0.5}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("loeschWahrscheinlichkeit", w)}
/>
<Regler
name="Tempo"
wert={tempo}
min={1}
max={60}
schritt={1}
format={(w) => `${w} Schritte/s`}
onChange={setTempo}
/>
</section>
<section className="karte simulation__verlauf">
<h2>Verlauf</h2>
<Verlaufsdiagramm daten={verlauf} serien={SERIEN} />
</section>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export interface Serie {
schluessel: string;
name: string;
farbe: string;
gestrichelt?: boolean;
}
export type Datenpunkt = { t: number } & Record<string, number>;
interface Props {
daten: Datenpunkt[];
serien: Serie[];
}
/** Prozent der Waldfläche über die Zeitschritte */
export default function Verlaufsdiagramm({ daten, serien }: Props) {
return (
<div className="diagramm">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daten} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" type="number" domain={[0, "dataMax"]} allowDecimals={false} tickLine={false} />
<YAxis domain={[0, 100]} unit=" %" width={52} tickLine={false} axisLine={false} />
<Tooltip
formatter={(wert) => `${Number(wert).toFixed(1)} %`}
labelFormatter={(t) => `Zeitschritt ${t}`}
contentStyle={{
background: "var(--flaeche)",
border: "1px solid var(--rand)",
borderRadius: 6,
color: "var(--text)",
}}
/>
<Legend iconType="plainline" />
{serien.map((s) => (
<Line
key={s.schluessel}
dataKey={s.schluessel}
name={s.name}
stroke={s.farbe}
strokeWidth={2}
strokeDasharray={s.gestrichelt ? "6 4" : undefined}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+272
View File
@@ -0,0 +1,272 @@
/**
* Waldbrand als zellulärer Automat – Portierung von python/ue1.py.
*
* Gleiche Regeln wie in Python, aber ein anderer Zufallsgenerator (mulberry32):
* Läufe mit demselben Seed sind reproduzierbar, stimmen aber nicht mit den Python-Läufen überein.
*/
export const WALD = 0;
export const BRENNEND = 1;
export const ABGEBRANNT = 2;
export const GELOESCHT = 3;
export const SCHNEISE = 4;
export const ZUSTAENDE = [
{ name: "Wald", farbe: "#008300" },
{ name: "brennend", farbe: "#e34948" },
{ name: "abgebrannt", farbe: "#3a3a38" },
{ name: "gelöscht", farbe: "#86b6ef" },
{ name: "Brandschneise", farbe: "#d9d4c7" },
] as const;
// Moore-Nachbarschaft: [Zeilenversatz, Spaltenversatz, Distanzgewicht]
export const NACHBARN: ReadonlyArray<readonly [number, number, number]> = [
[-1, -1, 0.7], [-1, 0, 1.0], [-1, 1, 0.7],
[0, -1, 1.0], [0, 1, 1.0],
[1, -1, 0.7], [1, 0, 1.0], [1, 1, 0.7],
];
export type Zelle = readonly [zeile: number, spalte: number];
export interface Params {
breite: number;
hoehe: number;
/** Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt */
schneiseAnteil: number;
/** Künstliche Brandschneisen als volle Zeilen bzw. Spalten */
schneisenZeilen: readonly number[];
schneisenSpalten: readonly number[];
/** Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt */
brandfaktorMin: number;
brandfaktorMax: number;
/** Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen) */
brenndauerMin: number;
brenndauerMax: number;
/** (x, y): x zeigt nach Osten, y nach Norden; Richtung, in die der Wind weht */
windRichtung: readonly [number, number];
windStaerke: number;
loeschWahrscheinlichkeit: number;
/** Startzelle, null = zufällige Waldzelle */
start: Zelle | null;
maxSchritte: number;
seed: number;
}
export const STANDARD_PARAMS: Params = {
breite: 100,
hoehe: 100,
schneiseAnteil: 0.05,
schneisenZeilen: [],
schneisenSpalten: [],
brandfaktorMin: 0.1,
brandfaktorMax: 1.0,
brenndauerMin: 2,
brenndauerMax: 5,
windRichtung: [1, 0],
windStaerke: 0,
loeschWahrscheinlichkeit: 0.05,
start: null,
maxSchritte: 1000,
seed: 0,
};
export interface Gitter {
breite: number;
hoehe: number;
zustand: Uint8Array;
brandfaktor: Float32Array;
restbrenndauer: Uint16Array;
}
export type Zufall = () => number;
/** mulberry32: kleiner, schneller Zufallsgenerator mit Seed, liefert Zahlen in [0, 1) */
export function zufallsgenerator(seed: number): Zufall {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle) */
export function windfaktor(dr: number, dc: number, [wx, wy]: readonly [number, number], staerke: number): number {
const normWind = Math.hypot(wx, wy);
if (staerke === 0 || normWind === 0) return 1;
// Zeilenindex wächst nach unten, y zeigt nach oben
const nx = dc;
const ny = -dr;
return 1 + (staerke * (wx * nx + wy * ny)) / (normWind * Math.hypot(nx, ny));
}
/** Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle. */
export function initGitter(params: Params, zufall: Zufall): Gitter {
const { breite, hoehe } = params;
const n = breite * hoehe;
const zustand = new Uint8Array(n);
for (let i = 0; i < n; i++) zustand[i] = zufall() < params.schneiseAnteil ? SCHNEISE : WALD;
if (params.start) {
// Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start[0] * breite + params.start[1]] = WALD;
}
for (const zeile of params.schneisenZeilen) zustand.fill(SCHNEISE, zeile * breite, (zeile + 1) * breite);
for (const spalte of params.schneisenSpalten) {
for (let r = 0; r < hoehe; r++) zustand[r * breite + spalte] = SCHNEISE;
}
// Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand.fill(SCHNEISE, 0, breite);
zustand.fill(SCHNEISE, (hoehe - 1) * breite, n);
for (let r = 0; r < hoehe; r++) {
zustand[r * breite] = SCHNEISE;
zustand[r * breite + breite - 1] = SCHNEISE;
}
let start: number;
if (params.start === null) {
// Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
// in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
const waldzellen: number[] = [];
for (let i = 0; i < n; i++) if (zustand[i] === WALD) waldzellen.push(i);
if (waldzellen.length === 0) throw new Error("Keine Waldzelle für den Brandherd vorhanden");
start = waldzellen[Math.floor(zufall() * waldzellen.length)];
} else {
start = params.start[0] * breite + params.start[1];
if (zustand[start] !== WALD) {
throw new Error(`Startzelle (${params.start.join(", ")}) liegt auf einer künstlichen Brandschneise oder am Rand`);
}
}
const brandfaktor = new Float32Array(n);
const restbrenndauer = new Uint16Array(n);
const spanne = params.brandfaktorMax - params.brandfaktorMin;
const dauern = params.brenndauerMax - params.brenndauerMin + 1;
for (let i = 0; i < n; i++) {
// Für jede Zelle ziehen, damit Varianten mit gleichem Seed denselben Wald erhalten
const bf = params.brandfaktorMin + zufall() * spanne;
const dauer = params.brenndauerMin + Math.floor(zufall() * dauern);
if (zustand[i] !== SCHNEISE) {
brandfaktor[i] = bf;
restbrenndauer[i] = dauer;
}
}
zustand[start] = BRENNEND;
return { breite, hoehe, zustand, brandfaktor, restbrenndauer };
}
/** Ein synchroner Zeitschritt: liest nur das alte Gitter und gibt ein neues zurück. */
export function schritt(gitter: Gitter, params: Params, zufall: Zufall): Gitter {
const { breite, hoehe, zustand, brandfaktor } = gitter;
const neu: Gitter = {
breite,
hoehe,
zustand: zustand.slice(),
brandfaktor: brandfaktor.slice(),
restbrenndauer: gitter.restbrenndauer.slice(),
};
const windfaktoren = NACHBARN.map(([dr, dc]) => windfaktor(dr, dc, params.windRichtung, params.windStaerke));
for (let r = 0; r < hoehe; r++) {
for (let c = 0; c < breite; c++) {
const i = r * breite + c;
if (zustand[i] !== BRENNEND) continue;
if (zufall() < params.loeschWahrscheinlichkeit) {
neu.zustand[i] = GELOESCHT;
neu.brandfaktor[i] = 0;
neu.restbrenndauer[i] = 0;
continue;
}
for (let k = 0; k < NACHBARN.length; k++) {
const [dr, dc, gewicht] = NACHBARN[k];
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= hoehe || nc < 0 || nc >= breite) continue;
const j = nr * breite + nc;
if (zustand[j] !== WALD) continue;
if (zufall() < 0.5 * gewicht * brandfaktor[j] * windfaktoren[k]) neu.zustand[j] = BRENNEND;
}
neu.restbrenndauer[i] -= 1;
if (neu.restbrenndauer[i] === 0) {
neu.zustand[i] = ABGEBRANNT;
neu.brandfaktor[i] = 0;
}
}
}
return neu;
}
export type Anzahl = number[];
export function zaehle(zustand: Uint8Array): Anzahl {
const anzahl = new Array<number>(ZUSTAENDE.length).fill(0);
for (const z of zustand) anzahl[z]++;
return anzahl;
}
/** Zellen, die irgendwann gebrannt haben */
export function betroffen(anzahl: Anzahl): number {
return anzahl[BRENNEND] + anzahl[ABGEBRANNT] + anzahl[GELOESCHT];
}
/** Jede Zelle, die keine Brandschneise ist, war zu Beginn Wald */
export function waldflaeche(anzahl: Anzahl): number {
return anzahl.reduce((a, b) => a + b, 0) - anzahl[SCHNEISE];
}
export interface Ergebnis {
breite: number;
hoehe: number;
endzustand: Uint8Array;
/** Anzahl Zellen je Zustand und Zeitschritt, anzahl[0] = Anfangszustand */
anzahl: Anzahl[];
dauer: number;
betroffenAnteil: number;
}
/** Simuliert, bis keine Zelle mehr brennt oder maxSchritte erreicht ist. */
export function simuliere(params: Params): Ergebnis {
const zufall = zufallsgenerator(params.seed);
let gitter = initGitter(params, zufall);
const anzahl = [zaehle(gitter.zustand)];
while (anzahl[anzahl.length - 1][BRENNEND] > 0 && anzahl.length <= params.maxSchritte) {
gitter = schritt(gitter, params, zufall);
anzahl.push(zaehle(gitter.zustand));
}
const letzte = anzahl[anzahl.length - 1];
return {
breite: gitter.breite,
hoehe: gitter.hoehe,
endzustand: gitter.zustand,
anzahl,
dauer: anzahl.length - 1,
betroffenAnteil: betroffen(letzte) / waldflaeche(letzte),
};
}
function imInneren(gitter: Gitter, [r, c]: Zelle): boolean {
return r > 0 && r < gitter.hoehe - 1 && c > 0 && c < gitter.breite - 1;
}
/** Macht eine Waldzelle zur Brandschneise (interaktives Zeichnen). Verändert das Gitter direkt. */
export function setzeSchneise(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = SCHNEISE;
gitter.brandfaktor[i] = 0;
gitter.restbrenndauer[i] = 0;
return true;
}
/** Entzündet eine Waldzelle (interaktiv). Verändert das Gitter direkt. */
export function entzuende(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = BRENNEND;
return true;
}
+403
View File
@@ -0,0 +1,403 @@
:root {
color-scheme: light;
--hintergrund: #f4f3f0;
--flaeche: #fcfcfb;
--text: #0b0b0b;
--text-2: #52514e;
--rand: #dcdad4;
--raster: #e6e4df;
--akzent: #2a78d6;
--akzent-text: #ffffff;
--fehler: #c62828;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--hintergrund: #121211;
--flaeche: #1a1a19;
--text: #f0efec;
--text-2: #c3c2b7;
--rand: #34332f;
--raster: #2a2a28;
--akzent: #3987e5;
--fehler: #ef7b7b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--hintergrund);
color: var(--text);
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
[hidden] {
display: none !important;
}
h1,
h2,
h3 {
margin: 0 0 12px;
line-height: 1.25;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.1rem;
}
h3 {
font-size: 0.95rem;
margin-top: 20px;
color: var(--text-2);
}
p {
margin: 0 0 12px;
}
.kopf {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.kopf p {
margin: 0;
color: var(--text-2);
}
.reiter,
.werkzeug,
.experiment-wahl {
display: inline-flex;
border: 1px solid var(--rand);
border-radius: 8px;
overflow: hidden;
background: var(--flaeche);
}
.experiment-wahl {
margin-bottom: 12px;
}
.reiter__tab,
.werkzeug__option {
border: 0;
background: transparent;
color: var(--text);
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.reiter__tab + .reiter__tab,
.werkzeug__option + .werkzeug__option {
border-left: 1px solid var(--rand);
}
.reiter__tab.aktiv,
.werkzeug__option.aktiv {
background: var(--akzent);
color: var(--akzent-text);
}
.karte {
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 12px;
padding: 16px;
min-width: 0;
}
.simulation {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"gitter parameter"
"verlauf parameter";
gap: 16px;
align-items: start;
}
.simulation__gitter {
grid-area: gitter;
}
.simulation__parameter {
grid-area: parameter;
}
.simulation__verlauf {
grid-area: verlauf;
}
@media (max-width: 860px) {
.simulation {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "gitter" "parameter" "verlauf";
}
}
.experimente {
display: grid;
gap: 16px;
}
.gitter {
display: block;
width: 100%;
max-width: 640px;
margin: 0 auto;
image-rendering: pixelated;
border-radius: 4px;
}
.gitter--interaktiv {
cursor: crosshair;
touch-action: none;
}
.legende {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
list-style: none;
padding: 0;
margin: 12px 0;
font-size: 0.875rem;
color: var(--text-2);
}
.legende li {
display: flex;
align-items: center;
gap: 6px;
}
.legende__farbe {
width: 12px;
height: 12px;
border-radius: 3px;
outline: 1px solid var(--rand);
}
.steuerung {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px;
margin-bottom: 12px;
}
.steuerung .werkzeug {
margin-left: auto;
}
.knopf {
border: 1px solid var(--rand);
background: var(--flaeche);
color: var(--text);
border-radius: 8px;
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.knopf:disabled {
opacity: 0.5;
cursor: default;
}
.knopf--primaer {
background: var(--akzent);
border-color: var(--akzent);
color: var(--akzent-text);
min-width: 88px;
}
.kennzahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.kennzahlen div {
border: 1px solid var(--rand);
border-radius: 8px;
padding: 8px 12px;
}
.kennzahlen dt {
font-size: 0.8rem;
color: var(--text-2);
}
.kennzahlen dd {
margin: 0;
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
}
.hinweis {
font-size: 0.875rem;
color: var(--text-2);
}
.feld {
display: grid;
gap: 4px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.feld--inline {
margin-bottom: 0;
}
.feld__zeile {
display: flex;
gap: 8px;
}
.feld__zeile input {
flex: 1;
min-width: 0;
}
input[type="number"],
select {
font: inherit;
color: var(--text);
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 8px;
padding: 7px 10px;
}
.feld--inline input {
width: 110px;
}
.regler {
display: grid;
gap: 2px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.regler__kopf {
display: flex;
justify-content: space-between;
}
.regler output {
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.regler input {
width: 100%;
accent-color: var(--akzent);
}
progress {
width: 100%;
accent-color: var(--akzent);
}
.fehler {
color: var(--fehler);
}
.tabelle-rahmen {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th,
td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--rand);
white-space: nowrap;
}
th {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-2);
}
.beispiele {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.beispiele figure {
margin: 0;
}
.beispiele .gitter {
max-width: 400px;
}
.beispiele figcaption {
margin-top: 8px;
font-size: 0.875rem;
text-align: center;
color: var(--text-2);
}
.beispiele strong {
color: var(--text);
}
/* Recharts zeichnet SVG; Farben über CSS, damit sie dem Farbschema folgen */
.diagramm .recharts-cartesian-grid line {
stroke: var(--raster);
}
.diagramm .recharts-cartesian-axis-line {
stroke: var(--rand);
}
.diagramm .recharts-text {
fill: var(--text-2);
font-size: 12px;
}
.diagramm .recharts-legend-item-text {
color: var(--text) !important;
}
.diagramm .recharts-tooltip-cursor {
stroke: var(--text-2);
}
+25
View File
@@ -0,0 +1,25 @@
// Führt Experimente im Hintergrund aus, damit die Oberfläche bedienbar bleibt
import { EXPERIMENTE, vergleiche, type ExperimentName, type Statistik } from "./experimente";
export interface Auftrag {
experiment: ExperimentName;
laeufe: number;
seed: number;
}
export type Nachricht =
| { typ: "fortschritt"; fertig: number; gesamt: number }
| { typ: "ergebnis"; statistik: Statistik[] };
// Die Typen der DOM-Bibliothek beschreiben ein Fenster, nicht einen Worker
const kanal = self as unknown as {
onmessage: ((e: MessageEvent<Auftrag>) => void) | null;
postMessage(nachricht: Nachricht): void;
};
kanal.onmessage = ({ data: { experiment, laeufe, seed } }) => {
const statistik = vergleiche(EXPERIMENTE[experiment].varianten, laeufe, seed, (fertig, gesamt) =>
kanal.postMessage({ typ: "fortschritt", fertig, gesamt }),
);
kanal.postMessage({ typ: "ergebnis", statistik });
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
// Pfad, unter dem die App ausgeliefert wird, z.B. BASE_PATH=/ue1 → https://sim.teier.net/ue1
// Absolut statt relativ, damit die Assets auch ohne abschließenden Schrägstrich gefunden werden
const pfad = (loadEnv(mode, ".", "").BASE_PATH ?? "").replace(/^\/+|\/+$/g, "");
return {
base: pfad ? `/${pfad}/` : "/",
plugins: [react()],
};
});