import type { Ratio, Seconds, VehiclesPerHour } from '../units'; import { isFiniteNumber, roundTo } from '../units'; import { isGreenAt } from '../plan/aspects'; import { hatSaettigungsverkehrsstaerke, type SignalPlan } from '../plan/signalPlan'; import { kfzServiceLevel, rueckstaulaenge } from '../rilsa/delay'; import type { Bewertungsverfahren, ServiceLevel } from '../rilsa/types'; import { demandFor, type Project } from '../model/project'; /** * Verkehrsablaufsimulation. * * Grundsatz: Die Simulation trifft keine eigenen Annahmen ueber die * Signalisierung. Wann eine Signalgruppe Freigabe zeigt, entnimmt sie * ausschliesslich dem fertigen Signalzeitenplan ueber `isGreenAt`. Der * Altbestand fuehrte hier eine zweite, vereinfachte Steuerung mit fest * eingetragenen 30 s je Phase; die Simulation zeigte damit einen Ablauf, den * die geplante Anlage gar nicht geschaltet haette. * * Modell: zeitdiskrete Warteschlange je Signalgruppe. * * Zufluss q / 3600 Fahrzeuge je Sekunde. Bei eingeschalteter * Ankunftsschwankung wird der Bruchteil eines Fahrzeugs * stochastisch gerundet; der Erwartungswert bleibt q / 3600. * Abfluss nur bei Freigabe und nur so viele Fahrzeuge, wie in der * Warteschlange stehen. Je Umlauf kann genau so viel abfliessen, * wie die Kapazitaet desselben Plans hergibt: qS * tA / 3600 mit der * Abflusszeit tA aus rilsa/capacity.ts. Zu Beginn jeder Freigabezeit * unterbindet die Anfahrverlustzeit den Abfluss. * * Auch beim Abfluss fuehrt die Simulation keine eigene Formel: Wie viel je * Umlauf durchkommt, sagt die Abflusszeit tA des Signalzeitenplans (HBS: * tF + 1 s je Freigabezeitfenster, HCM: tF). Zuvor liess die Simulation nur * waehrend tF minus Anfahrverlustzeit abfliessen - bei HBS 3 s je Fenster * weniger als die Bemessung. Der Rueckstau wuchs dadurch schon bei * Auslastungsgraden unter 1 unbegrenzt, und die Simulation widersprach der * Leistungsfaehigkeitsrechnung derselben Anwendung. * * Die Fahrzeugbilanz ist geschlossen: angekommen = abgeflossen + Warteschlange. * Diese Invariante wird im Test geprueft; im Altbestand konnten Fahrzeuge * verschwinden, weil der Abfluss nicht gegen die Warteschlangenlaenge begrenzt * war und negative Laengen entstanden. */ /** * Obergrenze, die ein einzelner Aufruf von `advance` nachrechnet - damit ein * langer Zeitsprung (etwa nach dem Wechsel in einen Hintergrund-Tab) die * Oberflaeche nicht einfriert. Wer mehr rechnen will, ruft mehrfach auf. */ export const MAX_NACHRECHENZEIT_JE_AUFRUF: Seconds = 60; export interface SimulationOptions { /** Zeitschritt in Sekunden. */ readonly timeStep?: Seconds; /** Zufaellige Ankunftsschwankung zulassen (mit festem Startwert, reproduzierbar). */ readonly stochastic?: boolean; /** Startwert des Zufallsgenerators. */ readonly seed?: number; /** Anfahrverlustzeit zu Beginn jeder Freigabezeit. */ readonly startUpLostTime?: Seconds; } export interface GroupSimulationState { readonly groupId: string; readonly name: string; readonly color: string; /** Fahrzeuge in der Warteschlange. */ readonly queue: number; /** Rueckstaulaenge in Metern. */ readonly queueLength: number; readonly maxQueue: number; readonly arrived: number; readonly departed: number; /** Mittlere Wartezeit ueber die bisher angekommenen Fahrzeuge. */ readonly averageDelay: Seconds; readonly serviceLevel: ServiceLevel; readonly demand: VehiclesPerHour; readonly saturationFlow: VehiclesPerHour; readonly green: boolean; } export interface SimulationSnapshot { /** Verstrichene Simulationszeit. */ readonly time: Seconds; /** Position im Umlauf. */ readonly cyclePosition: Seconds; readonly completedCycles: number; readonly groups: readonly GroupSimulationState[]; /** Mittlere Wartezeit ueber alle Signalgruppen, gewichtet mit der Verkehrsstaerke. */ readonly overallDelay: Seconds; readonly overallServiceLevel: ServiceLevel; /** Angekommene Fahrzeuge insgesamt. */ readonly totalArrived: number; readonly totalDeparted: number; readonly totalQueue: number; } interface GroupRuntime { readonly groupId: string; readonly name: string; readonly color: string; /** Verkehrsart fuer die Bewertungstafel (OePNV hat nach HBS 2015 eine eigene). */ readonly mode: 'kfz' | 'oepnv'; readonly demand: VehiclesPerHour; /** * Auslastungsgrad aus dem Signalzeitenplan (q / C). Nach HBS 2015 und * HCM 2010 ist die Stufe F an q > C gebunden, nicht an eine Wartezeit; die * Simulation bewertet deshalb mit demselben Auslastungsgrad wie der Plan. */ readonly degreeOfSaturation: Ratio | undefined; readonly saturationFlow: VehiclesPerHour; /** * Fahrstreifen der Signalgruppe - massgebend fuer die Rueckstaulaenge. * * Sie steht am Projekt und nicht am Plan: `PlannedSignalGroup` fuehrt sie * nicht, und sie dafuer aufzunehmen hiesse, die Planstruktur fuer eine reine * Anzeigegroesse zu erweitern. Die Simulation liest ohnehin aus beiden. */ readonly lanes: number; /** Anfahrverlustzeit zu Beginn jedes Freigabezeitfensters dieser Gruppe. */ readonly startUpLostTime: Seconds; /** * Streckung des Abflussfensters: Die nach der Anfahrverlustzeit verbleibende * Freigabezeit traegt die volle Abflusszeit tA des Plans. Abgeflossen wird * nur bei Freigabe - der Anteil, der nach HBS in die Gelbzeit hineinlaeuft, * hat dort keinen eigenen Platz. */ readonly abflussStreckung: number; queue: number; maxQueue: number; arrived: number; departed: number; /** Aufsummierte Wartezeit in Fahrzeugsekunden. */ delayIntegral: number; /** Restanteil eines Fahrzeugs aus dem letzten Zeitschritt. */ arrivalRemainder: number; departureRemainder: number; /** Wie lange zeigt die Gruppe schon Freigabe? Fuer die Anfahrverlustzeit. */ greenElapsed: Seconds; wasGreen: boolean; } /** Reproduzierbarer Zufallsgenerator (mulberry32). */ function createRandom(seed: number): () => number { let state = seed >>> 0; return () => { state += 0x6d2b79f5; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export class TrafficSimulation { private readonly runtimes: GroupRuntime[] = []; private readonly timeStep: Seconds; private readonly stochastic: boolean; private readonly startUpLostTime: Seconds; private random: () => number; private readonly seed: number; private time = 0; /** Noch nicht verrechnete Zeit, die fuer keinen ganzen Schritt mehr reicht. */ private pendingTime = 0; /* * Bewertungsverfahren des Projekts. Die Simulation rechnet keine * HBS-Wartezeit; sie legt die Stufentafel des gewaehlten Verfahrens an * den simulierten Mittelwert an - die Ansicht sagt das so. * * serviceLevelFor wurde ohne sie gerufen und fiel damit auf HBS zurueck, * waehrend der Signalzeitenplan die eingestellte Skala korrekt weiterreicht. * Dieselbe Wartezeit erhielt so in der Simulation eine andere Qualitaetsstufe * als in der Leistungsfaehigkeit - bei 31,9 s Stufe B statt C, also eine * Stufe zu gut, neben einer Tabelle, die "Bewertung nach HCM" ausweist. * Sichtbar wurde das erst, seit HCM das Speichern ueberlebt. */ private readonly serviceLevelScale: Bewertungsverfahren; constructor( private readonly plan: SignalPlan, project: Project, options: SimulationOptions = {}, ) { this.serviceLevelScale = project.settings.serviceLevelScale; this.timeStep = clampStep(options.timeStep ?? 0.5); this.stochastic = options.stochastic ?? false; this.seed = options.seed ?? 20260101; this.startUpLostTime = options.startUpLostTime ?? plan.defaults.startUpLostTime; this.random = createRandom(this.seed); for (const group of plan.groups) { // Fussgaenger und Radverkehr werden nicht als Warteschlange modelliert: // Fuer sie gibt es keine Saettigungsverkehrsstaerke im Sinne des HBS, // und bewertet werden sie ueber die maximale Wartezeit aus den // Signalzeiten (Fassung 5.4.0, Befund B4). Bis dahin liefen // Radgruppen hier mit einer Kfz-Saettigungsverkehrsstaerke. if (!hatSaettigungsverkehrsstaerke(group.mode)) continue; const demand = demandFor(project, group.groupId); if (!demand || demand.volume <= 0) continue; // Abflussfenster je Umlauf: die Abflusszeit tA, mit der die // Leistungsfaehigkeit desselben Plans rechnet. Fehlt eine // Kapazitaetsrechnung (nur bei Umlaufzeit 0 - dann ist die Simulation // ohnehin nicht lauffaehig), bleibt es bei der Freigabezeit. const abflusszeit = group.capacity?.abflusszeit ?? group.totalGreen; const fenster = Math.max(1, group.greens.length); // Zehrt die Anfahrverlustzeit die Freigabe ganz auf, entfaellt sie - // sonst bliebe kein Fenster, ueber das sich tA verteilen liesse. const nachAnfahrverlust = group.totalGreen - fenster * this.startUpLostTime; const startUpLostTime = nachAnfahrverlust > 0 ? this.startUpLostTime : 0; const abflussfenster = nachAnfahrverlust > 0 ? nachAnfahrverlust : group.totalGreen; this.runtimes.push({ groupId: group.groupId, name: group.name, color: group.color, mode: group.mode === 'oepnv' ? 'oepnv' : 'kfz', demand: demand.volume, degreeOfSaturation: group.capacity?.degreeOfSaturation, saturationFlow: group.capacity?.saturationFlow ?? plan.defaults.capacity.saturationFlow, lanes: project.signalGroups.find((g) => g.id === group.groupId)?.lanes ?? 1, startUpLostTime, abflussStreckung: abflussfenster > 0 ? abflusszeit / abflussfenster : 0, queue: 0, maxQueue: 0, arrived: 0, departed: 0, delayIntegral: 0, arrivalRemainder: 0, departureRemainder: 0, greenElapsed: 0, wasGreen: false, }); } } /** Sind ueberhaupt Daten fuer eine Simulation vorhanden? */ get runnable(): boolean { return this.runtimes.length > 0 && this.plan.cycleTime > 0; } /** * Angefangene Zeit, die fuer keinen ganzen Schritt mehr gereicht hat - * immer kleiner als `timeStep`. * * Gerechnet wird damit nichts; der Wert ist fuer die Anzeige da. Eine * Laufmarke, die sich gleichmaessig bewegen soll, schlaegt ihn auf die * Position im Umlauf auf: Die Rechnung bleibt in ganzen Schritten, nur das * Bild fuellt die Luecke dazwischen. */ get anstehendeZeit(): Seconds { return this.pendingTime; } reset(): void { this.time = 0; this.pendingTime = 0; this.random = createRandom(this.seed); for (const runtime of this.runtimes) { runtime.queue = 0; runtime.maxQueue = 0; runtime.arrived = 0; runtime.departed = 0; runtime.delayIntegral = 0; runtime.arrivalRemainder = 0; runtime.departureRemainder = 0; runtime.greenElapsed = 0; runtime.wasGreen = false; } } /** * Rechnet `duration` Sekunden Simulationszeit in festen Schritten von * `timeStep`. * * Was nicht mehr fuer einen ganzen Schritt reicht, bleibt im Restzeitkonto * stehen und wird beim naechsten Aufruf mitgerechnet. Ohne dieses Konto * bestimmte der Aufrufer die Schrittweite: Die Simulationsansicht uebergibt je * Einzelbild die verstrichene Zeit mal Ablaufgeschwindigkeit, bei 60 Bildern * je Sekunde und Echtzeit also 0,017 s statt der vorgegebenen 0,5 s. Dieselbe * Anlage lieferte damit je nach Bildwiederholrate und Geschwindigkeit andere * Rueckstaus und Wartezeiten - und Schrittweiten, die `clampStep` als Vorgabe * gar nicht zugelassen haette. */ advance(duration: Seconds): void { if (!this.runnable || !isFiniteNumber(duration) || duration <= 0) return; this.pendingTime += Math.min(duration, MAX_NACHRECHENZEIT_JE_AUFRUF); while (this.pendingTime >= this.timeStep - 1e-9) { this.stepOnce(this.timeStep); this.pendingTime -= this.timeStep; } if (this.pendingTime < 0) this.pendingTime = 0; } private stepOnce(dt: Seconds): void { const cycle = this.plan.cycleTime; const position = ((this.time % cycle) + cycle) % cycle; for (const runtime of this.runtimes) { const planned = this.plan.groups.find((g) => g.groupId === runtime.groupId); const green = planned ? isGreenAt(planned, position, cycle) : false; if (green && !runtime.wasGreen) runtime.greenElapsed = 0; runtime.wasGreen = green; // --- Zufluss --------------------------------------------------------- const expectedArrivals = (runtime.demand / 3600) * dt + runtime.arrivalRemainder; let arrivals = Math.floor(expectedArrivals); const rest = expectedArrivals - arrivals; if (this.stochastic) { /* * Stochastisches Runden: Mit der Wahrscheinlichkeit `rest` kommt ein * Fahrzeug mehr an, sonst keines - und der Rest ist in BEIDEN Faellen * verbraucht. Der Erwartungswert bleibt damit genau q / 3600 je Sekunde. * * Zuvor wurde der Rest nur beim Feuern der Ziehung auf 0 gesetzt, beim * Nichtfeuern aber weitergetragen: Der Zufluss lief doppelt, je Schritt * mit einem Ueberschuss von rest * (1 - rest) >= 0. Nach einer Stunde * kamen bei 620 Fz/h rund 1800 Fahrzeuge an, und der Rueckstau wuchs * auch bei deutlich unterausgelasteten Gruppen unbegrenzt. */ if (rest > 0 && this.random() < rest) arrivals += 1; runtime.arrivalRemainder = 0; } else { runtime.arrivalRemainder = rest; } runtime.queue += arrivals; runtime.arrived += arrivals; // --- Abfluss --------------------------------------------------------- if (green) { runtime.greenElapsed += dt; // Waehrend der Anfahrverlustzeit fliesst noch nichts ab; die // verbleibende Freigabezeit traegt dafuer die volle Abflusszeit tA des // Plans (siehe `abflussStreckung`). const offen = Math.max(0, Math.min(dt, runtime.greenElapsed - runtime.startUpLostTime)); const effective = offen * runtime.abflussStreckung; const capacity = (runtime.saturationFlow / 3600) * effective + runtime.departureRemainder; const departures = Math.min(runtime.queue, Math.floor(capacity)); runtime.departureRemainder = capacity - Math.floor(capacity); runtime.queue -= departures; runtime.departed += departures; } else { // Der aufgelaufene Bruchteil eines Fahrzeugs bleibt stehen. Wurde er am // Freigabezeitende verworfen, fehlte je Umlauf bis zu ein Fahrzeug, und // der Abfluss blieb hinter der Kapazitaet zurueck. runtime.greenElapsed = 0; } // Die Warteschlange kann durch die Begrenzung oben nie negativ werden; // die Zusicherung haelt einen kuenftigen Fehler frueh auf. if (runtime.queue < 0) runtime.queue = 0; runtime.maxQueue = Math.max(runtime.maxQueue, runtime.queue); runtime.delayIntegral += runtime.queue * dt; } this.time += dt; } snapshot(): SimulationSnapshot { const cycle = this.plan.cycleTime; const position = cycle > 0 ? ((this.time % cycle) + cycle) % cycle : 0; const groups: GroupSimulationState[] = this.runtimes.map((runtime) => { const planned = this.plan.groups.find((g) => g.groupId === runtime.groupId); const roh = runtime.arrived > 0 ? runtime.delayIntegral / runtime.arrived : 0; // Bewertet wird der Wert, der auch angezeigt wird - dieselbe Ueberlegung // wie in rilsa/delay.ts. Eine Zeile "35,0 s | Stufe B" neben einer // Stufengrenze bei 35 s ist fuer den Betrachter nicht aufloesbar. const averageDelay = roundTo(roh, 1); return { groupId: runtime.groupId, name: runtime.name, color: runtime.color, queue: runtime.queue, queueLength: rueckstaulaenge(runtime.queue, runtime.lanes), maxQueue: runtime.maxQueue, arrived: runtime.arrived, departed: runtime.departed, averageDelay, serviceLevel: kfzServiceLevel( averageDelay, this.serviceLevelScale, runtime.degreeOfSaturation, runtime.mode, ), demand: runtime.demand, saturationFlow: runtime.saturationFlow, green: planned ? isGreenAt(planned, position, cycle) : false, }; }); const totalArrived = groups.reduce((a, g) => a + g.arrived, 0); const weightedDelay = groups.reduce((a, g) => a + g.averageDelay * g.arrived, 0); const overallDelay = roundTo(totalArrived > 0 ? weightedDelay / totalArrived : 0, 1); // Ist ein Strom ueberlastet, ist es der Knotenpunkt: Der hoechste // Auslastungsgrad entscheidet ueber die Stufe F der Gesamtbewertung. const hoechsteAuslastung = this.runtimes.reduce( (max, r) => r.degreeOfSaturation === undefined ? max : max === undefined ? r.degreeOfSaturation : Math.max(max, r.degreeOfSaturation), undefined, ); return { time: roundTo(this.time, 2), cyclePosition: roundTo(position, 2), completedCycles: cycle > 0 ? Math.floor(this.time / cycle) : 0, groups, overallDelay, overallServiceLevel: kfzServiceLevel( overallDelay, this.serviceLevelScale, hoechsteAuslastung, ), totalArrived, totalDeparted: groups.reduce((a, g) => a + g.departed, 0), totalQueue: groups.reduce((a, g) => a + g.queue, 0), }; } } function clampStep(value: Seconds): Seconds { if (!isFiniteNumber(value) || value <= 0) return 0.5; return Math.min(2, Math.max(0.05, value)); }