Files
modellierung-und-simulation…/web/src/modell.ts
T

273 lines
9.3 KiB
TypeScript
Raw 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.
/**
* 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;
}