lsa-planer

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

/ src domain rilsa coordination.ts

16,8 KB Rohdatei
src/domain/rilsa/coordination.ts — 438 Zeilen
1 import type { KilometersPerHour, Meters, Seconds } from '../units';
2 import { isFiniteNumber, kmhToMs, roundTo } from '../units';
3 import { RILSA_DEFAULTS, RILSA_KOORDINIERUNG, type RilsaDefaults } from './constants';
4 import type { CalculationNote } from './types';
5
6 /** Ein Knotenpunkt im Zuge einer Koordinierung. */
7 export interface CoordinatedIntersection {
8 readonly id: string;
9 readonly name: string;
10 /** Abstand zum vorhergehenden Knotenpunkt in Metern (beim ersten: 0). */
11 readonly distanceToPrevious: Meters;
12 /** Beginn der Freigabezeit der Hauptrichtung, bezogen auf den Umlaufbeginn. */
13 readonly greenStart: Seconds;
14 /** Dauer der Freigabezeit der Hauptrichtung. */
15 readonly greenDuration: Seconds;
16 /**
17 * Beginn der Freigabezeit der GEGENRICHTUNG, bezogen auf den Umlaufbeginn.
18 *
19 * Optional, und das ist die ehrliche Form: Wer die Gegenrichtung nicht
20 * erfasst hat, bekommt kein zweites Band - und nicht eines, das eine
21 * Annahme abbildet. Eine Koordinierung ohne Gegenrichtung ist die halbe
22 * Aussage: Der Zielkonflikt zwischen Hin- und Rueckrichtung IST der
23 * eigentliche Gegenstand der Abstimmung.
24 */
25 readonly returnGreenStart?: Seconds;
26 /** Dauer der Freigabezeit der Gegenrichtung. */
27 readonly returnGreenDuration?: Seconds;
28 }
29
30 /** Ein durchgehendes Band: wo es liegt, wie breit es ist, was es taugt. */
31 export interface Band {
32 /**
33 * Beginn des Bandes am ERSTEN Knotenpunkt der Fahrtrichtung, bezogen auf
34 * dessen Umlaufbeginn (0 .. tU).
35 *
36 * OHNE DIE LAGE IST DAS BAND NICHT ZEICHENBAR, und darum geht es hier: Ein
37 * Zeit-Weg-Diagramm zeigt, WANN ein Fahrzeug losfahren muss, um ohne Halt
38 * durchzukommen. Die Breite allein sagt nur, wie lang dieses Zeitfenster
39 * ist, nicht wo es liegt.
40 */
41 readonly beginn: Seconds;
42 /** Nutzbare Bandbreite in Sekunden. */
43 readonly breite: Seconds;
44 /** Bandbreite bezogen auf die Umlaufzeit. */
45 readonly anteil: number;
46 readonly quality: 'gut' | 'brauchbar' | 'gering' | 'keine';
47 }
48
49 export interface CoordinationResult {
50 readonly cycleTime: Seconds;
51 readonly speed: KilometersPerHour;
52 readonly intersections: readonly {
53 readonly id: string;
54 readonly name: string;
55 /** Kumulierte Reisezeit ab dem ersten Knotenpunkt. */
56 readonly travelTime: Seconds;
57 /**
58 * Aufsummierte Entfernung ab dem ersten Knotenpunkt in Metern.
59 *
60 * Die Wegachse des Zeit-Weg-Diagramms. Aus den Abstaenden zum Vorgaenger
61 * liesse sie sich zwar bilden, aber dann bildete sie jede Ansicht selbst -
62 * und die Wegachse eines Diagramms und die Tabelle daneben liefen
63 * auseinander, sobald eine von beiden anders rundet.
64 */
65 readonly cumulativeDistance: Meters;
66 /** Empfohlener Versatz, bezogen auf den ersten Knotenpunkt (0 .. tU). */
67 readonly offset: Seconds;
68 }[];
69 /** Nutzbare Bandbreite in Sekunden in Fahrtrichtung. */
70 readonly bandwidth: Seconds;
71 /** Bandbreite bezogen auf die Umlaufzeit. */
72 readonly bandwidthRatio: number;
73 readonly quality: 'gut' | 'brauchbar' | 'gering' | 'keine';
74 /** Das Band der Hinrichtung mit seiner Lage - dieselben Zahlen wie oben. */
75 readonly hinrichtung: Band;
76 /**
77 * Das Band der Gegenrichtung, oder `null`.
78 *
79 * `null` heisst: An mindestens einem Knotenpunkt ist die Gegenrichtung nicht
80 * erfasst. Dann gibt es kein zweites Band - und keine Annahme darueber.
81 */
82 readonly rueckrichtung: Band | null;
83 readonly notes: readonly CalculationNote[];
84 }
85
86 /**
87 * Ermittelt Versatzzeiten und nutzbare Bandbreite einer Gruenen Welle.
88 *
89 * Der Versatz eines Knotenpunkts ergibt sich aus der kumulierten Reisezeit
90 * modulo Umlaufzeit. Die Bandbreite ist der zeitliche Ueberlappungsbereich
91 * aller Freigabefenster, jeweils um die Reisezeit verschoben - auf dem Kreis
92 * der Laenge tU, denn ein Signalprogramm wiederholt sich mit jedem Umlauf.
93 *
94 * Der Altbestand bestimmte die Bandbreite als "kleinste Freigabezeit minus 5 s"
95 * und beruecksichtigte die Versatzzeiten dabei ueberhaupt nicht. Eine
96 * Koordinierung mit voellig unpassenden Versaetzen erhielt so dieselbe
97 * Bewertung wie eine optimal abgestimmte.
98 *
99 * Ohne angegebene Progressionsgeschwindigkeit gilt der Vorgabewert aus
100 * `defaults` - den Projektvorgaben, nicht den Regelwerten. Beides kann
101 * auseinanderfallen, sobald die Vorgabenverwaltung coordinationDefaultSpeed
102 * geaendert hat.
103 */
104 export function computeCoordination(
105 intersections: readonly CoordinatedIntersection[],
106 cycleTime: Seconds,
107 progressionsgeschwindigkeit?: KilometersPerHour,
108 defaults: RilsaDefaults = RILSA_DEFAULTS,
109 zulaessigeHoechstgeschwindigkeit?: KilometersPerHour,
110 ): CoordinationResult {
111 const notes: CalculationNote[] = [];
112 const speed = progressionsgeschwindigkeit ?? defaults.coordination.defaultSpeed;
113
114 if (intersections.length < 2) {
115 notes.push({
116 severity: 'fehler',
117 code: 'zu-wenige-knoten',
118 message: 'Eine Koordinierung setzt mindestens zwei Knotenpunkte voraus.',
119 });
120 return emptyResult(cycleTime, speed, notes);
121 }
122
123 if (!isFiniteNumber(cycleTime) || cycleTime <= 0) {
124 notes.push({
125 severity: 'fehler',
126 code: 'umlaufzeit-ungueltig',
127 message: 'Die Umlaufzeit ist kein gültiger Wert größer als 0.',
128 });
129 return emptyResult(cycleTime, speed, notes);
130 }
131
132 if (!isFiniteNumber(speed) || speed <= 0) {
133 notes.push({
134 severity: 'fehler',
135 code: 'geschwindigkeit-ungueltig',
136 message: 'Die Progressionsgeschwindigkeit ist kein gültiger Wert größer als 0.',
137 });
138 return emptyResult(cycleTime, speed, notes);
139 }
140
141 if (speed < defaults.coordination.minSpeed || speed > defaults.coordination.maxSpeed) {
142 notes.push({
143 severity: 'warnung',
144 code: 'geschwindigkeit-ausserhalb-bereich',
145 message:
146 `Die Progressionsgeschwindigkeit ${format(speed)} km/h liegt außerhalb des üblichen ` +
147 `Bereichs von ${defaults.coordination.minSpeed} bis ${defaults.coordination.maxSpeed} km/h.`,
148 });
149 }
150
151 /*
152 * Die Regel der RiLSA ist RELATIV (Abschnitt 4.4.4.2): 90 bis 100 Prozent
153 * der zulaessigen Hoechstgeschwindigkeit. Der Bereich darueber ist absolut
154 * und stammt aus keinem Regelwerk; beide bleiben nebeneinander stehen, weil
155 * sie Verschiedenes sagen. Ohne Vzul gibt es die relative Aussage nicht -
156 * dann schweigt sie, statt eine anzunehmen.
157 */
158 if (isFiniteNumber(zulaessigeHoechstgeschwindigkeit) && zulaessigeHoechstgeschwindigkeit > 0) {
159 const unten = zulaessigeHoechstgeschwindigkeit * RILSA_KOORDINIERUNG.progressionsanteilVon;
160 const oben = zulaessigeHoechstgeschwindigkeit * RILSA_KOORDINIERUNG.progressionsanteilBis;
161 if (speed < unten || speed > oben) {
162 notes.push({
163 severity: 'warnung',
164 code: 'progressionsgeschwindigkeit-ausserhalb-rilsa',
165 message:
166 `Die Progressionsgeschwindigkeit ${format(speed)} km/h liegt außerhalb von 90 bis ` +
167 `100 % der zulässigen Höchstgeschwindigkeit von ` +
168 `${format(zulaessigeHoechstgeschwindigkeit)} km/h (${format(unten)} bis ` +
169 `${format(oben)} km/h). Die RiLSA 2015 empfiehlt in Abschnitt 4.4.4.2 diesen Bereich.`,
170 });
171 }
172 }
173
174 /*
175 * Wirkbereich Gruener Wellen (RiLSA 2015, Abschnitt 4.4.4.1): bis 750 m
176 * wirksam, in besonders guenstigen Faellen bis 1000 m; darueber loesen sich
177 * die Pulks so weit auf, dass eine Koordinierung in der Regel nicht mehr
178 * sinnvoll ist. Gemeldet wird je Knotenpunkt, weil der Abstand dort haengt -
179 * eine Summe ueber den Strassenzug sagte nichts.
180 */
181 for (const node of intersections.slice(1)) {
182 const abstand = isFiniteNumber(node.distanceToPrevious) ? node.distanceToPrevious : 0;
183 if (abstand > RILSA_KOORDINIERUNG.wirkbereichGuenstig) {
184 notes.push({
185 severity: 'warnung',
186 code: 'abstand-ueber-wirkbereich',
187 message:
188 `Der Abstand von ${format(abstand)} m vor "${node.name}" überschreitet auch die ` +
189 `${RILSA_KOORDINIERUNG.wirkbereichGuenstig} m, bis zu denen eine Grüne Welle nach ` +
190 'RiLSA 2015 in besonders günstigen Fällen noch wirkt. Der Fahrzeugpulk löst sich auf; ' +
191 'eine Koordinierung ist hier in der Regel nicht mehr sinnvoll.',
192 });
193 } else if (abstand > RILSA_KOORDINIERUNG.wirkbereichRegel) {
194 notes.push({
195 severity: 'hinweis',
196 code: 'abstand-ueber-regelwirkbereich',
197 message:
198 `Der Abstand von ${format(abstand)} m vor "${node.name}" überschreitet die ` +
199 `${RILSA_KOORDINIERUNG.wirkbereichRegel} m des Regelwirkbereichs. Nach RiLSA 2015 ` +
200 `wirken Grüne Wellen darüber nur in besonders günstigen Fällen, längstens bis ` +
201 `${RILSA_KOORDINIERUNG.wirkbereichGuenstig} m.`,
202 });
203 }
204 }
205
206 const vMs = kmhToMs(speed);
207 let cumulativeTravel = 0;
208 let cumulativeDistance = 0;
209 const rows: {
210 id: string;
211 name: string;
212 travelTime: Seconds;
213 cumulativeDistance: Meters;
214 offset: Seconds;
215 }[] = [];
216 // Ungerundete Reisezeiten fuer die Bandbreite: die Tabellenspalte ist auf
217 // 0,1 s gerundet, und die Abweichungen summierten sich entlang der Kette auf.
218 const reisezeiten: Seconds[] = [];
219
220 for (const [index, node] of intersections.entries()) {
221 if (index > 0) {
222 const distance = isFiniteNumber(node.distanceToPrevious)
223 ? Math.max(0, node.distanceToPrevious)
224 : 0;
225 if (distance === 0) {
226 notes.push({
227 severity: 'warnung',
228 code: 'abstand-null',
229 message: `Für "${node.name}" ist kein Abstand zum vorhergehenden Knotenpunkt angegeben.`,
230 });
231 }
232 cumulativeDistance += distance;
233 cumulativeTravel += distance / vMs;
234 }
235 reisezeiten.push(cumulativeTravel);
236 rows.push({
237 id: node.id,
238 name: node.name,
239 travelTime: roundTo(cumulativeTravel, 1),
240 cumulativeDistance: roundTo(cumulativeDistance, 1),
241 offset: roundTo(modulo(cumulativeTravel, cycleTime), 1),
242 });
243 }
244
245 /*
246 * Bandbreite: Schnittmenge aller Freigabefenster, zurueckgerechnet auf den
247 * ersten Knotenpunkt. Ein Fahrzeug, das dort zum Zeitpunkt t startet, trifft
248 * Knoten i zum Zeitpunkt t + reisezeit_i an.
249 *
250 * KORREKTUR (Fassung 5.10.0, Befunde S2 und S4): Die Fenster
251 * werden mit modulo in den Umlauf gefaltet - so, wie es die Versatzzeile
252 * darueber schon immer tat. Zuvor wurde "greenStart - Reisezeit" auf der
253 * Geraden geschnitten; sobald dieser Wert unter den Umlaufbeginn fiel, meldete
254 * die Rechnung "keine Bandbreite" - fuer genau die Versaetze, die sie in
255 * intersections[i].offset selbst empfiehlt.
256 */
257 const fenster: { start: Seconds; duration: Seconds }[] = [];
258
259 for (const [index, node] of intersections.entries()) {
260 const travel = reisezeiten[index] ?? 0;
261 const duration = isFiniteNumber(node.greenDuration) ? Math.max(0, node.greenDuration) : 0;
262 if (duration === 0) {
263 notes.push({
264 severity: 'fehler',
265 code: 'keine-freigabezeit',
266 message: `Für "${node.name}" ist keine Freigabezeit der Hauptrichtung angegeben.`,
267 });
268 return { ...emptyResult(cycleTime, speed, notes), intersections: rows };
269 }
270 // Ein Fenster ueber den ganzen Umlauf schraenkt nichts ein.
271 if (duration >= cycleTime) continue;
272 const greenStart = isFiniteNumber(node.greenStart) ? node.greenStart : 0;
273 fenster.push({ start: modulo(greenStart - travel, cycleTime), duration });
274 }
275
276 const hin = bandAus(groessterGemeinsamerBogen(fenster, cycleTime), cycleTime);
277 const bandwidth = hin.breite;
278 const bandwidthRatio = hin.anteil;
279
280 /*
281 * DIE GEGENRICHTUNG. Sie faehrt die Kette rueckwaerts: Ein Fahrzeug startet
282 * am LETZTEN Knotenpunkt, und bis zum Knotenpunkt i braucht es die Zeit
283 * (Gesamtreisezeit - Reisezeit_i). Bezugspunkt des Bandes ist damit der
284 * Umlaufbeginn des letzten Knotenpunkts.
285 *
286 * Fehlt an einem einzigen Knotenpunkt die Angabe, gibt es KEIN zweites Band.
287 * Eine Gegenrichtung, die stillschweigend die Fenster der Hinrichtung
288 * benutzt, waere eine erfundene Aussage ueber die halbe Koordinierung - und
289 * der Zielkonflikt zwischen den beiden Richtungen ist der eigentliche
290 * Gegenstand der Abstimmung.
291 */
292 const gesamtreise = reisezeiten[reisezeiten.length - 1] ?? 0;
293 const rueckFenster: { start: Seconds; duration: Seconds }[] = [];
294 let rueckVollstaendig = true;
295 for (const [index, node] of intersections.entries()) {
296 const dauer = node.returnGreenDuration;
297 const beginn = node.returnGreenStart;
298 if (!isFiniteNumber(dauer) || dauer <= 0 || !isFiniteNumber(beginn)) {
299 rueckVollstaendig = false;
300 break;
301 }
302 if (dauer >= cycleTime) continue;
303 const travel = gesamtreise - (reisezeiten[index] ?? 0);
304 rueckFenster.push({ start: modulo(beginn - travel, cycleTime), duration: dauer });
305 }
306 const rueckrichtung = rueckVollstaendig
307 ? bandAus(groessterGemeinsamerBogen(rueckFenster, cycleTime), cycleTime)
308 : null;
309
310 if (rueckrichtung !== null && rueckrichtung.breite === 0) {
311 notes.push({
312 severity: 'warnung',
313 code: 'keine-bandbreite-gegenrichtung',
314 message:
315 'In der Gegenrichtung entsteht mit diesen Freigabezeiten und Versaetzen kein ' +
316 'durchgehendes Band. Der Zielkonflikt zwischen den beiden Fahrtrichtungen ist damit zu ' +
317 'Lasten der Gegenrichtung entschieden - das ist eine Planungsentscheidung und in den ' +
318 'Unterlagen zu begruenden.',
319 });
320 }
321
322 if (bandwidth === 0) {
323 notes.push({
324 severity: 'fehler',
325 code: 'keine-bandbreite',
326 message:
327 'Mit den angegebenen Freigabezeiten und Versätzen entsteht keine durchgehende Bandbreite. ' +
328 'Die Versatzzeiten sind anzupassen oder die Freigabezeiten der Hauptrichtung zu verlängern.',
329 });
330 }
331
332 return {
333 cycleTime,
334 speed,
335 intersections: rows,
336 bandwidth: roundTo(bandwidth, 1),
337 bandwidthRatio: roundTo(bandwidthRatio, 3),
338 quality: rateBandwidth(bandwidthRatio),
339 hinrichtung: hin,
340 rueckrichtung,
341 notes,
342 };
343 }
344
345 /** Aus Lage und Laenge eines Bogens ein bewertetes Band. */
346 function bandAus(bogen: { beginn: Seconds; laenge: Seconds }, cycleTime: Seconds): Band {
347 const anteil = cycleTime > 0 ? bogen.laenge / cycleTime : 0;
348 return {
349 beginn: roundTo(modulo(bogen.beginn, cycleTime), 1),
350 breite: roundTo(bogen.laenge, 1),
351 anteil: roundTo(anteil, 3),
352 quality: rateBandwidth(anteil),
353 };
354 }
355
356 /**
357 * Laengster zusammenhaengender Bogen, der in allen Fenstern liegt - die
358 * Fenster als Boegen eines Kreises der Laenge tU verstanden.
359 *
360 * Die Schnittmenge mehrerer Kreisboegen kann in mehrere getrennte Boegen
361 * zerfallen; nutzbar ist der laengste, nicht ihre Summe: ein Fahrzeugpulk faehrt
362 * in einem Zug durch. Jeder dieser Boegen beginnt am Anfang eines der Fenster,
363 * deshalb genuegt es, diese Anfaenge als Kandidaten zu pruefen. Ohne
364 * einschraenkendes Fenster ist der ganze Umlauf nutzbar.
365 */
366 function groessterGemeinsamerBogen(
367 fenster: readonly { readonly start: Seconds; readonly duration: Seconds }[],
368 cycleTime: Seconds,
369 ): { beginn: Seconds; laenge: Seconds } {
370 if (fenster.length === 0) return { beginn: 0, laenge: cycleTime };
371 let laengster = 0;
372 let beginn = 0;
373 for (const kandidat of fenster) {
374 let laenge = cycleTime;
375 for (const f of fenster) {
376 let abstand = modulo(kandidat.start - f.start, cycleTime);
377 // Ein Rest knapp unterhalb der Umlaufzeit ist ein Rundungsrest von 0.
378 if (abstand > cycleTime - 1e-9) abstand = 0;
379 if (abstand >= f.duration) {
380 laenge = 0;
381 break;
382 }
383 laenge = Math.min(laenge, f.duration - abstand);
384 }
385 if (laenge > laengster) {
386 laengster = laenge;
387 beginn = kandidat.start;
388 }
389 }
390 return { beginn, laenge: Math.min(cycleTime, laengster) };
391 }
392
393 /**
394 * Bewertung der Bandbreite. Als brauchbar gilt eine Gruene Welle ueblicherweise
395 * ab etwa einem Viertel der Umlaufzeit.
396 */
397 function rateBandwidth(ratio: number): CoordinationResult['quality'] {
398 if (ratio <= 0) return 'keine';
399 if (ratio >= 0.35) return 'gut';
400 if (ratio >= 0.25) return 'brauchbar';
401 return 'gering';
402 }
403
404 /** Reisezeit zwischen zwei Knotenpunkten. */
405 export function travelTime(distance: Meters, speed: KilometersPerHour): Seconds {
406 if (!isFiniteNumber(distance) || distance < 0) return 0;
407 if (!isFiniteNumber(speed) || speed <= 0) return Number.POSITIVE_INFINITY;
408 return distance / kmhToMs(speed);
409 }
410
411 /** Positiver Rest, auch fuer negative Eingaben. */
412 export function modulo(value: number, m: number): number {
413 if (m <= 0) return 0;
414 return ((value % m) + m) % m;
415 }
416
417 function emptyResult(
418 cycleTime: Seconds,
419 speed: KilometersPerHour,
420 notes: CalculationNote[],
421 ): CoordinationResult {
422 const leer: Band = { beginn: 0, breite: 0, anteil: 0, quality: 'keine' };
423 return {
424 cycleTime,
425 speed,
426 intersections: [],
427 bandwidth: 0,
428 bandwidthRatio: 0,
429 quality: 'keine',
430 hinrichtung: leer,
431 rueckrichtung: null,
432 notes,
433 };
434 }
435
436 function format(value: number): string {
437 return roundTo(value, 1).toString().replace('.', ',');
438 }