lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ src domain simulation simulation.ts

17,5 KB Rohdatei
src/domain/simulation/simulation.ts — 429 Zeilen
1 import type { Ratio, Seconds, VehiclesPerHour } from '../units';
2 import { isFiniteNumber, roundTo } from '../units';
3 import { isGreenAt } from '../plan/aspects';
4 import { hatSaettigungsverkehrsstaerke, type SignalPlan } from '../plan/signalPlan';
5 import { kfzServiceLevel, rueckstaulaenge } from '../rilsa/delay';
6 import type { Bewertungsverfahren, ServiceLevel } from '../rilsa/types';
7 import { demandFor, type Project } from '../model/project';
8
9 /**
10 * Verkehrsablaufsimulation.
11 *
12 * Grundsatz: Die Simulation trifft keine eigenen Annahmen ueber die
13 * Signalisierung. Wann eine Signalgruppe Freigabe zeigt, entnimmt sie
14 * ausschliesslich dem fertigen Signalzeitenplan ueber `isGreenAt`. Der
15 * Altbestand fuehrte hier eine zweite, vereinfachte Steuerung mit fest
16 * eingetragenen 30 s je Phase; die Simulation zeigte damit einen Ablauf, den
17 * die geplante Anlage gar nicht geschaltet haette.
18 *
19 * Modell: zeitdiskrete Warteschlange je Signalgruppe.
20 *
21 * Zufluss q / 3600 Fahrzeuge je Sekunde. Bei eingeschalteter
22 * Ankunftsschwankung wird der Bruchteil eines Fahrzeugs
23 * stochastisch gerundet; der Erwartungswert bleibt q / 3600.
24 * Abfluss nur bei Freigabe und nur so viele Fahrzeuge, wie in der
25 * Warteschlange stehen. Je Umlauf kann genau so viel abfliessen,
26 * wie die Kapazitaet desselben Plans hergibt: qS * tA / 3600 mit der
27 * Abflusszeit tA aus rilsa/capacity.ts. Zu Beginn jeder Freigabezeit
28 * unterbindet die Anfahrverlustzeit den Abfluss.
29 *
30 * Auch beim Abfluss fuehrt die Simulation keine eigene Formel: Wie viel je
31 * Umlauf durchkommt, sagt die Abflusszeit tA des Signalzeitenplans (HBS:
32 * tF + 1 s je Freigabezeitfenster, HCM: tF). Zuvor liess die Simulation nur
33 * waehrend tF minus Anfahrverlustzeit abfliessen - bei HBS 3 s je Fenster
34 * weniger als die Bemessung. Der Rueckstau wuchs dadurch schon bei
35 * Auslastungsgraden unter 1 unbegrenzt, und die Simulation widersprach der
36 * Leistungsfaehigkeitsrechnung derselben Anwendung.
37 *
38 * Die Fahrzeugbilanz ist geschlossen: angekommen = abgeflossen + Warteschlange.
39 * Diese Invariante wird im Test geprueft; im Altbestand konnten Fahrzeuge
40 * verschwinden, weil der Abfluss nicht gegen die Warteschlangenlaenge begrenzt
41 * war und negative Laengen entstanden.
42 */
43
44 /**
45 * Obergrenze, die ein einzelner Aufruf von `advance` nachrechnet - damit ein
46 * langer Zeitsprung (etwa nach dem Wechsel in einen Hintergrund-Tab) die
47 * Oberflaeche nicht einfriert. Wer mehr rechnen will, ruft mehrfach auf.
48 */
49 export const MAX_NACHRECHENZEIT_JE_AUFRUF: Seconds = 60;
50
51 export interface SimulationOptions {
52 /** Zeitschritt in Sekunden. */
53 readonly timeStep?: Seconds;
54 /** Zufaellige Ankunftsschwankung zulassen (mit festem Startwert, reproduzierbar). */
55 readonly stochastic?: boolean;
56 /** Startwert des Zufallsgenerators. */
57 readonly seed?: number;
58 /** Anfahrverlustzeit zu Beginn jeder Freigabezeit. */
59 readonly startUpLostTime?: Seconds;
60 }
61
62 export interface GroupSimulationState {
63 readonly groupId: string;
64 readonly name: string;
65 readonly color: string;
66 /** Fahrzeuge in der Warteschlange. */
67 readonly queue: number;
68 /** Rueckstaulaenge in Metern. */
69 readonly queueLength: number;
70 readonly maxQueue: number;
71 readonly arrived: number;
72 readonly departed: number;
73 /** Mittlere Wartezeit ueber die bisher angekommenen Fahrzeuge. */
74 readonly averageDelay: Seconds;
75 readonly serviceLevel: ServiceLevel;
76 readonly demand: VehiclesPerHour;
77 readonly saturationFlow: VehiclesPerHour;
78 readonly green: boolean;
79 }
80
81 export interface SimulationSnapshot {
82 /** Verstrichene Simulationszeit. */
83 readonly time: Seconds;
84 /** Position im Umlauf. */
85 readonly cyclePosition: Seconds;
86 readonly completedCycles: number;
87 readonly groups: readonly GroupSimulationState[];
88 /** Mittlere Wartezeit ueber alle Signalgruppen, gewichtet mit der Verkehrsstaerke. */
89 readonly overallDelay: Seconds;
90 readonly overallServiceLevel: ServiceLevel;
91 /** Angekommene Fahrzeuge insgesamt. */
92 readonly totalArrived: number;
93 readonly totalDeparted: number;
94 readonly totalQueue: number;
95 }
96
97 interface GroupRuntime {
98 readonly groupId: string;
99 readonly name: string;
100 readonly color: string;
101 /** Verkehrsart fuer die Bewertungstafel (OePNV hat nach HBS 2015 eine eigene). */
102 readonly mode: 'kfz' | 'oepnv';
103 readonly demand: VehiclesPerHour;
104 /**
105 * Auslastungsgrad aus dem Signalzeitenplan (q / C). Nach HBS 2015 und
106 * HCM 2010 ist die Stufe F an q > C gebunden, nicht an eine Wartezeit; die
107 * Simulation bewertet deshalb mit demselben Auslastungsgrad wie der Plan.
108 */
109 readonly degreeOfSaturation: Ratio | undefined;
110 readonly saturationFlow: VehiclesPerHour;
111 /**
112 * Fahrstreifen der Signalgruppe - massgebend fuer die Rueckstaulaenge.
113 *
114 * Sie steht am Projekt und nicht am Plan: `PlannedSignalGroup` fuehrt sie
115 * nicht, und sie dafuer aufzunehmen hiesse, die Planstruktur fuer eine reine
116 * Anzeigegroesse zu erweitern. Die Simulation liest ohnehin aus beiden.
117 */
118 readonly lanes: number;
119 /** Anfahrverlustzeit zu Beginn jedes Freigabezeitfensters dieser Gruppe. */
120 readonly startUpLostTime: Seconds;
121 /**
122 * Streckung des Abflussfensters: Die nach der Anfahrverlustzeit verbleibende
123 * Freigabezeit traegt die volle Abflusszeit tA des Plans. Abgeflossen wird
124 * nur bei Freigabe - der Anteil, der nach HBS in die Gelbzeit hineinlaeuft,
125 * hat dort keinen eigenen Platz.
126 */
127 readonly abflussStreckung: number;
128 queue: number;
129 maxQueue: number;
130 arrived: number;
131 departed: number;
132 /** Aufsummierte Wartezeit in Fahrzeugsekunden. */
133 delayIntegral: number;
134 /** Restanteil eines Fahrzeugs aus dem letzten Zeitschritt. */
135 arrivalRemainder: number;
136 departureRemainder: number;
137 /** Wie lange zeigt die Gruppe schon Freigabe? Fuer die Anfahrverlustzeit. */
138 greenElapsed: Seconds;
139 wasGreen: boolean;
140 }
141
142 /** Reproduzierbarer Zufallsgenerator (mulberry32). */
143 function createRandom(seed: number): () => number {
144 let state = seed >>> 0;
145 return () => {
146 state += 0x6d2b79f5;
147 let t = state;
148 t = Math.imul(t ^ (t >>> 15), t | 1);
149 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
150 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
151 };
152 }
153
154 export class TrafficSimulation {
155 private readonly runtimes: GroupRuntime[] = [];
156 private readonly timeStep: Seconds;
157 private readonly stochastic: boolean;
158 private readonly startUpLostTime: Seconds;
159 private random: () => number;
160 private readonly seed: number;
161 private time = 0;
162 /** Noch nicht verrechnete Zeit, die fuer keinen ganzen Schritt mehr reicht. */
163 private pendingTime = 0;
164 /*
165 * Bewertungsverfahren des Projekts. Die Simulation rechnet keine
166 * HBS-Wartezeit; sie legt die Stufentafel des gewaehlten Verfahrens an
167 * den simulierten Mittelwert an - die Ansicht sagt das so.
168 *
169 * serviceLevelFor wurde ohne sie gerufen und fiel damit auf HBS zurueck,
170 * waehrend der Signalzeitenplan die eingestellte Skala korrekt weiterreicht.
171 * Dieselbe Wartezeit erhielt so in der Simulation eine andere Qualitaetsstufe
172 * als in der Leistungsfaehigkeit - bei 31,9 s Stufe B statt C, also eine
173 * Stufe zu gut, neben einer Tabelle, die "Bewertung nach HCM" ausweist.
174 * Sichtbar wurde das erst, seit HCM das Speichern ueberlebt.
175 */
176 private readonly serviceLevelScale: Bewertungsverfahren;
177
178 constructor(
179 private readonly plan: SignalPlan,
180 project: Project,
181 options: SimulationOptions = {},
182 ) {
183 this.serviceLevelScale = project.settings.serviceLevelScale;
184 this.timeStep = clampStep(options.timeStep ?? 0.5);
185 this.stochastic = options.stochastic ?? false;
186 this.seed = options.seed ?? 20260101;
187 this.startUpLostTime = options.startUpLostTime ?? plan.defaults.startUpLostTime;
188 this.random = createRandom(this.seed);
189
190 for (const group of plan.groups) {
191 // Fussgaenger und Radverkehr werden nicht als Warteschlange modelliert:
192 // Fuer sie gibt es keine Saettigungsverkehrsstaerke im Sinne des HBS,
193 // und bewertet werden sie ueber die maximale Wartezeit aus den
194 // Signalzeiten (Fassung 5.4.0, Befund B4). Bis dahin liefen
195 // Radgruppen hier mit einer Kfz-Saettigungsverkehrsstaerke.
196 if (!hatSaettigungsverkehrsstaerke(group.mode)) continue;
197 const demand = demandFor(project, group.groupId);
198 if (!demand || demand.volume <= 0) continue;
199
200 // Abflussfenster je Umlauf: die Abflusszeit tA, mit der die
201 // Leistungsfaehigkeit desselben Plans rechnet. Fehlt eine
202 // Kapazitaetsrechnung (nur bei Umlaufzeit 0 - dann ist die Simulation
203 // ohnehin nicht lauffaehig), bleibt es bei der Freigabezeit.
204 const abflusszeit = group.capacity?.abflusszeit ?? group.totalGreen;
205 const fenster = Math.max(1, group.greens.length);
206 // Zehrt die Anfahrverlustzeit die Freigabe ganz auf, entfaellt sie -
207 // sonst bliebe kein Fenster, ueber das sich tA verteilen liesse.
208 const nachAnfahrverlust = group.totalGreen - fenster * this.startUpLostTime;
209 const startUpLostTime = nachAnfahrverlust > 0 ? this.startUpLostTime : 0;
210 const abflussfenster = nachAnfahrverlust > 0 ? nachAnfahrverlust : group.totalGreen;
211
212 this.runtimes.push({
213 groupId: group.groupId,
214 name: group.name,
215 color: group.color,
216 mode: group.mode === 'oepnv' ? 'oepnv' : 'kfz',
217 demand: demand.volume,
218 degreeOfSaturation: group.capacity?.degreeOfSaturation,
219 saturationFlow: group.capacity?.saturationFlow ?? plan.defaults.capacity.saturationFlow,
220 lanes: project.signalGroups.find((g) => g.id === group.groupId)?.lanes ?? 1,
221 startUpLostTime,
222 abflussStreckung: abflussfenster > 0 ? abflusszeit / abflussfenster : 0,
223 queue: 0,
224 maxQueue: 0,
225 arrived: 0,
226 departed: 0,
227 delayIntegral: 0,
228 arrivalRemainder: 0,
229 departureRemainder: 0,
230 greenElapsed: 0,
231 wasGreen: false,
232 });
233 }
234 }
235
236 /** Sind ueberhaupt Daten fuer eine Simulation vorhanden? */
237 get runnable(): boolean {
238 return this.runtimes.length > 0 && this.plan.cycleTime > 0;
239 }
240
241 /**
242 * Angefangene Zeit, die fuer keinen ganzen Schritt mehr gereicht hat -
243 * immer kleiner als `timeStep`.
244 *
245 * Gerechnet wird damit nichts; der Wert ist fuer die Anzeige da. Eine
246 * Laufmarke, die sich gleichmaessig bewegen soll, schlaegt ihn auf die
247 * Position im Umlauf auf: Die Rechnung bleibt in ganzen Schritten, nur das
248 * Bild fuellt die Luecke dazwischen.
249 */
250 get anstehendeZeit(): Seconds {
251 return this.pendingTime;
252 }
253
254 reset(): void {
255 this.time = 0;
256 this.pendingTime = 0;
257 this.random = createRandom(this.seed);
258 for (const runtime of this.runtimes) {
259 runtime.queue = 0;
260 runtime.maxQueue = 0;
261 runtime.arrived = 0;
262 runtime.departed = 0;
263 runtime.delayIntegral = 0;
264 runtime.arrivalRemainder = 0;
265 runtime.departureRemainder = 0;
266 runtime.greenElapsed = 0;
267 runtime.wasGreen = false;
268 }
269 }
270
271 /**
272 * Rechnet `duration` Sekunden Simulationszeit in festen Schritten von
273 * `timeStep`.
274 *
275 * Was nicht mehr fuer einen ganzen Schritt reicht, bleibt im Restzeitkonto
276 * stehen und wird beim naechsten Aufruf mitgerechnet. Ohne dieses Konto
277 * bestimmte der Aufrufer die Schrittweite: Die Simulationsansicht uebergibt je
278 * Einzelbild die verstrichene Zeit mal Ablaufgeschwindigkeit, bei 60 Bildern
279 * je Sekunde und Echtzeit also 0,017 s statt der vorgegebenen 0,5 s. Dieselbe
280 * Anlage lieferte damit je nach Bildwiederholrate und Geschwindigkeit andere
281 * Rueckstaus und Wartezeiten - und Schrittweiten, die `clampStep` als Vorgabe
282 * gar nicht zugelassen haette.
283 */
284 advance(duration: Seconds): void {
285 if (!this.runnable || !isFiniteNumber(duration) || duration <= 0) return;
286 this.pendingTime += Math.min(duration, MAX_NACHRECHENZEIT_JE_AUFRUF);
287 while (this.pendingTime >= this.timeStep - 1e-9) {
288 this.stepOnce(this.timeStep);
289 this.pendingTime -= this.timeStep;
290 }
291 if (this.pendingTime < 0) this.pendingTime = 0;
292 }
293
294 private stepOnce(dt: Seconds): void {
295 const cycle = this.plan.cycleTime;
296 const position = ((this.time % cycle) + cycle) % cycle;
297
298 for (const runtime of this.runtimes) {
299 const planned = this.plan.groups.find((g) => g.groupId === runtime.groupId);
300 const green = planned ? isGreenAt(planned, position, cycle) : false;
301
302 if (green && !runtime.wasGreen) runtime.greenElapsed = 0;
303 runtime.wasGreen = green;
304
305 // --- Zufluss ---------------------------------------------------------
306 const expectedArrivals = (runtime.demand / 3600) * dt + runtime.arrivalRemainder;
307 let arrivals = Math.floor(expectedArrivals);
308 const rest = expectedArrivals - arrivals;
309 if (this.stochastic) {
310 /*
311 * Stochastisches Runden: Mit der Wahrscheinlichkeit `rest` kommt ein
312 * Fahrzeug mehr an, sonst keines - und der Rest ist in BEIDEN Faellen
313 * verbraucht. Der Erwartungswert bleibt damit genau q / 3600 je Sekunde.
314 *
315 * Zuvor wurde der Rest nur beim Feuern der Ziehung auf 0 gesetzt, beim
316 * Nichtfeuern aber weitergetragen: Der Zufluss lief doppelt, je Schritt
317 * mit einem Ueberschuss von rest * (1 - rest) >= 0. Nach einer Stunde
318 * kamen bei 620 Fz/h rund 1800 Fahrzeuge an, und der Rueckstau wuchs
319 * auch bei deutlich unterausgelasteten Gruppen unbegrenzt.
320 */
321 if (rest > 0 && this.random() < rest) arrivals += 1;
322 runtime.arrivalRemainder = 0;
323 } else {
324 runtime.arrivalRemainder = rest;
325 }
326 runtime.queue += arrivals;
327 runtime.arrived += arrivals;
328
329 // --- Abfluss ---------------------------------------------------------
330 if (green) {
331 runtime.greenElapsed += dt;
332 // Waehrend der Anfahrverlustzeit fliesst noch nichts ab; die
333 // verbleibende Freigabezeit traegt dafuer die volle Abflusszeit tA des
334 // Plans (siehe `abflussStreckung`).
335 const offen = Math.max(0, Math.min(dt, runtime.greenElapsed - runtime.startUpLostTime));
336 const effective = offen * runtime.abflussStreckung;
337 const capacity = (runtime.saturationFlow / 3600) * effective + runtime.departureRemainder;
338 const departures = Math.min(runtime.queue, Math.floor(capacity));
339 runtime.departureRemainder = capacity - Math.floor(capacity);
340 runtime.queue -= departures;
341 runtime.departed += departures;
342 } else {
343 // Der aufgelaufene Bruchteil eines Fahrzeugs bleibt stehen. Wurde er am
344 // Freigabezeitende verworfen, fehlte je Umlauf bis zu ein Fahrzeug, und
345 // der Abfluss blieb hinter der Kapazitaet zurueck.
346 runtime.greenElapsed = 0;
347 }
348
349 // Die Warteschlange kann durch die Begrenzung oben nie negativ werden;
350 // die Zusicherung haelt einen kuenftigen Fehler frueh auf.
351 if (runtime.queue < 0) runtime.queue = 0;
352
353 runtime.maxQueue = Math.max(runtime.maxQueue, runtime.queue);
354 runtime.delayIntegral += runtime.queue * dt;
355 }
356
357 this.time += dt;
358 }
359
360 snapshot(): SimulationSnapshot {
361 const cycle = this.plan.cycleTime;
362 const position = cycle > 0 ? ((this.time % cycle) + cycle) % cycle : 0;
363
364 const groups: GroupSimulationState[] = this.runtimes.map((runtime) => {
365 const planned = this.plan.groups.find((g) => g.groupId === runtime.groupId);
366 const roh = runtime.arrived > 0 ? runtime.delayIntegral / runtime.arrived : 0;
367 // Bewertet wird der Wert, der auch angezeigt wird - dieselbe Ueberlegung
368 // wie in rilsa/delay.ts. Eine Zeile "35,0 s | Stufe B" neben einer
369 // Stufengrenze bei 35 s ist fuer den Betrachter nicht aufloesbar.
370 const averageDelay = roundTo(roh, 1);
371 return {
372 groupId: runtime.groupId,
373 name: runtime.name,
374 color: runtime.color,
375 queue: runtime.queue,
376 queueLength: rueckstaulaenge(runtime.queue, runtime.lanes),
377 maxQueue: runtime.maxQueue,
378 arrived: runtime.arrived,
379 departed: runtime.departed,
380 averageDelay,
381 serviceLevel: kfzServiceLevel(
382 averageDelay,
383 this.serviceLevelScale,
384 runtime.degreeOfSaturation,
385 runtime.mode,
386 ),
387 demand: runtime.demand,
388 saturationFlow: runtime.saturationFlow,
389 green: planned ? isGreenAt(planned, position, cycle) : false,
390 };
391 });
392
393 const totalArrived = groups.reduce((a, g) => a + g.arrived, 0);
394 const weightedDelay = groups.reduce((a, g) => a + g.averageDelay * g.arrived, 0);
395 const overallDelay = roundTo(totalArrived > 0 ? weightedDelay / totalArrived : 0, 1);
396 // Ist ein Strom ueberlastet, ist es der Knotenpunkt: Der hoechste
397 // Auslastungsgrad entscheidet ueber die Stufe F der Gesamtbewertung.
398 const hoechsteAuslastung = this.runtimes.reduce<Ratio | undefined>(
399 (max, r) =>
400 r.degreeOfSaturation === undefined
401 ? max
402 : max === undefined
403 ? r.degreeOfSaturation
404 : Math.max(max, r.degreeOfSaturation),
405 undefined,
406 );
407
408 return {
409 time: roundTo(this.time, 2),
410 cyclePosition: roundTo(position, 2),
411 completedCycles: cycle > 0 ? Math.floor(this.time / cycle) : 0,
412 groups,
413 overallDelay,
414 overallServiceLevel: kfzServiceLevel(
415 overallDelay,
416 this.serviceLevelScale,
417 hoechsteAuslastung,
418 ),
419 totalArrived,
420 totalDeparted: groups.reduce((a, g) => a + g.departed, 0),
421 totalQueue: groups.reduce((a, g) => a + g.queue, 0),
422 };
423 }
424 }
425
426 function clampStep(value: Seconds): Seconds {
427 if (!isFiniteNumber(value) || value <= 0) return 0.5;
428 return Math.min(2, Math.max(0.05, value));
429 }