import type { KilometersPerHour, Meters, Seconds } from '../units'; import { isFiniteNumber, kmhToMs, roundTo } from '../units'; import { RILSA_DEFAULTS, RILSA_KOORDINIERUNG, type RilsaDefaults } from './constants'; import type { CalculationNote } from './types'; /** Ein Knotenpunkt im Zuge einer Koordinierung. */ export interface CoordinatedIntersection { readonly id: string; readonly name: string; /** Abstand zum vorhergehenden Knotenpunkt in Metern (beim ersten: 0). */ readonly distanceToPrevious: Meters; /** Beginn der Freigabezeit der Hauptrichtung, bezogen auf den Umlaufbeginn. */ readonly greenStart: Seconds; /** Dauer der Freigabezeit der Hauptrichtung. */ readonly greenDuration: Seconds; /** * Beginn der Freigabezeit der GEGENRICHTUNG, bezogen auf den Umlaufbeginn. * * Optional, und das ist die ehrliche Form: Wer die Gegenrichtung nicht * erfasst hat, bekommt kein zweites Band - und nicht eines, das eine * Annahme abbildet. Eine Koordinierung ohne Gegenrichtung ist die halbe * Aussage: Der Zielkonflikt zwischen Hin- und Rueckrichtung IST der * eigentliche Gegenstand der Abstimmung. */ readonly returnGreenStart?: Seconds; /** Dauer der Freigabezeit der Gegenrichtung. */ readonly returnGreenDuration?: Seconds; } /** Ein durchgehendes Band: wo es liegt, wie breit es ist, was es taugt. */ export interface Band { /** * Beginn des Bandes am ERSTEN Knotenpunkt der Fahrtrichtung, bezogen auf * dessen Umlaufbeginn (0 .. tU). * * OHNE DIE LAGE IST DAS BAND NICHT ZEICHENBAR, und darum geht es hier: Ein * Zeit-Weg-Diagramm zeigt, WANN ein Fahrzeug losfahren muss, um ohne Halt * durchzukommen. Die Breite allein sagt nur, wie lang dieses Zeitfenster * ist, nicht wo es liegt. */ readonly beginn: Seconds; /** Nutzbare Bandbreite in Sekunden. */ readonly breite: Seconds; /** Bandbreite bezogen auf die Umlaufzeit. */ readonly anteil: number; readonly quality: 'gut' | 'brauchbar' | 'gering' | 'keine'; } export interface CoordinationResult { readonly cycleTime: Seconds; readonly speed: KilometersPerHour; readonly intersections: readonly { readonly id: string; readonly name: string; /** Kumulierte Reisezeit ab dem ersten Knotenpunkt. */ readonly travelTime: Seconds; /** * Aufsummierte Entfernung ab dem ersten Knotenpunkt in Metern. * * Die Wegachse des Zeit-Weg-Diagramms. Aus den Abstaenden zum Vorgaenger * liesse sie sich zwar bilden, aber dann bildete sie jede Ansicht selbst - * und die Wegachse eines Diagramms und die Tabelle daneben liefen * auseinander, sobald eine von beiden anders rundet. */ readonly cumulativeDistance: Meters; /** Empfohlener Versatz, bezogen auf den ersten Knotenpunkt (0 .. tU). */ readonly offset: Seconds; }[]; /** Nutzbare Bandbreite in Sekunden in Fahrtrichtung. */ readonly bandwidth: Seconds; /** Bandbreite bezogen auf die Umlaufzeit. */ readonly bandwidthRatio: number; readonly quality: 'gut' | 'brauchbar' | 'gering' | 'keine'; /** Das Band der Hinrichtung mit seiner Lage - dieselben Zahlen wie oben. */ readonly hinrichtung: Band; /** * Das Band der Gegenrichtung, oder `null`. * * `null` heisst: An mindestens einem Knotenpunkt ist die Gegenrichtung nicht * erfasst. Dann gibt es kein zweites Band - und keine Annahme darueber. */ readonly rueckrichtung: Band | null; readonly notes: readonly CalculationNote[]; } /** * Ermittelt Versatzzeiten und nutzbare Bandbreite einer Gruenen Welle. * * Der Versatz eines Knotenpunkts ergibt sich aus der kumulierten Reisezeit * modulo Umlaufzeit. Die Bandbreite ist der zeitliche Ueberlappungsbereich * aller Freigabefenster, jeweils um die Reisezeit verschoben - auf dem Kreis * der Laenge tU, denn ein Signalprogramm wiederholt sich mit jedem Umlauf. * * Der Altbestand bestimmte die Bandbreite als "kleinste Freigabezeit minus 5 s" * und beruecksichtigte die Versatzzeiten dabei ueberhaupt nicht. Eine * Koordinierung mit voellig unpassenden Versaetzen erhielt so dieselbe * Bewertung wie eine optimal abgestimmte. * * Ohne angegebene Progressionsgeschwindigkeit gilt der Vorgabewert aus * `defaults` - den Projektvorgaben, nicht den Regelwerten. Beides kann * auseinanderfallen, sobald die Vorgabenverwaltung coordinationDefaultSpeed * geaendert hat. */ export function computeCoordination( intersections: readonly CoordinatedIntersection[], cycleTime: Seconds, progressionsgeschwindigkeit?: KilometersPerHour, defaults: RilsaDefaults = RILSA_DEFAULTS, zulaessigeHoechstgeschwindigkeit?: KilometersPerHour, ): CoordinationResult { const notes: CalculationNote[] = []; const speed = progressionsgeschwindigkeit ?? defaults.coordination.defaultSpeed; if (intersections.length < 2) { notes.push({ severity: 'fehler', code: 'zu-wenige-knoten', message: 'Eine Koordinierung setzt mindestens zwei Knotenpunkte voraus.', }); return emptyResult(cycleTime, speed, notes); } if (!isFiniteNumber(cycleTime) || cycleTime <= 0) { notes.push({ severity: 'fehler', code: 'umlaufzeit-ungueltig', message: 'Die Umlaufzeit ist kein gültiger Wert größer als 0.', }); return emptyResult(cycleTime, speed, notes); } if (!isFiniteNumber(speed) || speed <= 0) { notes.push({ severity: 'fehler', code: 'geschwindigkeit-ungueltig', message: 'Die Progressionsgeschwindigkeit ist kein gültiger Wert größer als 0.', }); return emptyResult(cycleTime, speed, notes); } if (speed < defaults.coordination.minSpeed || speed > defaults.coordination.maxSpeed) { notes.push({ severity: 'warnung', code: 'geschwindigkeit-ausserhalb-bereich', message: `Die Progressionsgeschwindigkeit ${format(speed)} km/h liegt außerhalb des üblichen ` + `Bereichs von ${defaults.coordination.minSpeed} bis ${defaults.coordination.maxSpeed} km/h.`, }); } /* * Die Regel der RiLSA ist RELATIV (Abschnitt 4.4.4.2): 90 bis 100 Prozent * der zulaessigen Hoechstgeschwindigkeit. Der Bereich darueber ist absolut * und stammt aus keinem Regelwerk; beide bleiben nebeneinander stehen, weil * sie Verschiedenes sagen. Ohne Vzul gibt es die relative Aussage nicht - * dann schweigt sie, statt eine anzunehmen. */ if (isFiniteNumber(zulaessigeHoechstgeschwindigkeit) && zulaessigeHoechstgeschwindigkeit > 0) { const unten = zulaessigeHoechstgeschwindigkeit * RILSA_KOORDINIERUNG.progressionsanteilVon; const oben = zulaessigeHoechstgeschwindigkeit * RILSA_KOORDINIERUNG.progressionsanteilBis; if (speed < unten || speed > oben) { notes.push({ severity: 'warnung', code: 'progressionsgeschwindigkeit-ausserhalb-rilsa', message: `Die Progressionsgeschwindigkeit ${format(speed)} km/h liegt außerhalb von 90 bis ` + `100 % der zulässigen Höchstgeschwindigkeit von ` + `${format(zulaessigeHoechstgeschwindigkeit)} km/h (${format(unten)} bis ` + `${format(oben)} km/h). Die RiLSA 2015 empfiehlt in Abschnitt 4.4.4.2 diesen Bereich.`, }); } } /* * Wirkbereich Gruener Wellen (RiLSA 2015, Abschnitt 4.4.4.1): bis 750 m * wirksam, in besonders guenstigen Faellen bis 1000 m; darueber loesen sich * die Pulks so weit auf, dass eine Koordinierung in der Regel nicht mehr * sinnvoll ist. Gemeldet wird je Knotenpunkt, weil der Abstand dort haengt - * eine Summe ueber den Strassenzug sagte nichts. */ for (const node of intersections.slice(1)) { const abstand = isFiniteNumber(node.distanceToPrevious) ? node.distanceToPrevious : 0; if (abstand > RILSA_KOORDINIERUNG.wirkbereichGuenstig) { notes.push({ severity: 'warnung', code: 'abstand-ueber-wirkbereich', message: `Der Abstand von ${format(abstand)} m vor "${node.name}" überschreitet auch die ` + `${RILSA_KOORDINIERUNG.wirkbereichGuenstig} m, bis zu denen eine Grüne Welle nach ` + 'RiLSA 2015 in besonders günstigen Fällen noch wirkt. Der Fahrzeugpulk löst sich auf; ' + 'eine Koordinierung ist hier in der Regel nicht mehr sinnvoll.', }); } else if (abstand > RILSA_KOORDINIERUNG.wirkbereichRegel) { notes.push({ severity: 'hinweis', code: 'abstand-ueber-regelwirkbereich', message: `Der Abstand von ${format(abstand)} m vor "${node.name}" überschreitet die ` + `${RILSA_KOORDINIERUNG.wirkbereichRegel} m des Regelwirkbereichs. Nach RiLSA 2015 ` + `wirken Grüne Wellen darüber nur in besonders günstigen Fällen, längstens bis ` + `${RILSA_KOORDINIERUNG.wirkbereichGuenstig} m.`, }); } } const vMs = kmhToMs(speed); let cumulativeTravel = 0; let cumulativeDistance = 0; const rows: { id: string; name: string; travelTime: Seconds; cumulativeDistance: Meters; offset: Seconds; }[] = []; // Ungerundete Reisezeiten fuer die Bandbreite: die Tabellenspalte ist auf // 0,1 s gerundet, und die Abweichungen summierten sich entlang der Kette auf. const reisezeiten: Seconds[] = []; for (const [index, node] of intersections.entries()) { if (index > 0) { const distance = isFiniteNumber(node.distanceToPrevious) ? Math.max(0, node.distanceToPrevious) : 0; if (distance === 0) { notes.push({ severity: 'warnung', code: 'abstand-null', message: `Für "${node.name}" ist kein Abstand zum vorhergehenden Knotenpunkt angegeben.`, }); } cumulativeDistance += distance; cumulativeTravel += distance / vMs; } reisezeiten.push(cumulativeTravel); rows.push({ id: node.id, name: node.name, travelTime: roundTo(cumulativeTravel, 1), cumulativeDistance: roundTo(cumulativeDistance, 1), offset: roundTo(modulo(cumulativeTravel, cycleTime), 1), }); } /* * Bandbreite: Schnittmenge aller Freigabefenster, zurueckgerechnet auf den * ersten Knotenpunkt. Ein Fahrzeug, das dort zum Zeitpunkt t startet, trifft * Knoten i zum Zeitpunkt t + reisezeit_i an. * * KORREKTUR (Fassung 5.10.0, Befunde S2 und S4): Die Fenster * werden mit modulo in den Umlauf gefaltet - so, wie es die Versatzzeile * darueber schon immer tat. Zuvor wurde "greenStart - Reisezeit" auf der * Geraden geschnitten; sobald dieser Wert unter den Umlaufbeginn fiel, meldete * die Rechnung "keine Bandbreite" - fuer genau die Versaetze, die sie in * intersections[i].offset selbst empfiehlt. */ const fenster: { start: Seconds; duration: Seconds }[] = []; for (const [index, node] of intersections.entries()) { const travel = reisezeiten[index] ?? 0; const duration = isFiniteNumber(node.greenDuration) ? Math.max(0, node.greenDuration) : 0; if (duration === 0) { notes.push({ severity: 'fehler', code: 'keine-freigabezeit', message: `Für "${node.name}" ist keine Freigabezeit der Hauptrichtung angegeben.`, }); return { ...emptyResult(cycleTime, speed, notes), intersections: rows }; } // Ein Fenster ueber den ganzen Umlauf schraenkt nichts ein. if (duration >= cycleTime) continue; const greenStart = isFiniteNumber(node.greenStart) ? node.greenStart : 0; fenster.push({ start: modulo(greenStart - travel, cycleTime), duration }); } const hin = bandAus(groessterGemeinsamerBogen(fenster, cycleTime), cycleTime); const bandwidth = hin.breite; const bandwidthRatio = hin.anteil; /* * DIE GEGENRICHTUNG. Sie faehrt die Kette rueckwaerts: Ein Fahrzeug startet * am LETZTEN Knotenpunkt, und bis zum Knotenpunkt i braucht es die Zeit * (Gesamtreisezeit - Reisezeit_i). Bezugspunkt des Bandes ist damit der * Umlaufbeginn des letzten Knotenpunkts. * * Fehlt an einem einzigen Knotenpunkt die Angabe, gibt es KEIN zweites Band. * Eine Gegenrichtung, die stillschweigend die Fenster der Hinrichtung * benutzt, waere eine erfundene Aussage ueber die halbe Koordinierung - und * der Zielkonflikt zwischen den beiden Richtungen ist der eigentliche * Gegenstand der Abstimmung. */ const gesamtreise = reisezeiten[reisezeiten.length - 1] ?? 0; const rueckFenster: { start: Seconds; duration: Seconds }[] = []; let rueckVollstaendig = true; for (const [index, node] of intersections.entries()) { const dauer = node.returnGreenDuration; const beginn = node.returnGreenStart; if (!isFiniteNumber(dauer) || dauer <= 0 || !isFiniteNumber(beginn)) { rueckVollstaendig = false; break; } if (dauer >= cycleTime) continue; const travel = gesamtreise - (reisezeiten[index] ?? 0); rueckFenster.push({ start: modulo(beginn - travel, cycleTime), duration: dauer }); } const rueckrichtung = rueckVollstaendig ? bandAus(groessterGemeinsamerBogen(rueckFenster, cycleTime), cycleTime) : null; if (rueckrichtung !== null && rueckrichtung.breite === 0) { notes.push({ severity: 'warnung', code: 'keine-bandbreite-gegenrichtung', message: 'In der Gegenrichtung entsteht mit diesen Freigabezeiten und Versaetzen kein ' + 'durchgehendes Band. Der Zielkonflikt zwischen den beiden Fahrtrichtungen ist damit zu ' + 'Lasten der Gegenrichtung entschieden - das ist eine Planungsentscheidung und in den ' + 'Unterlagen zu begruenden.', }); } if (bandwidth === 0) { notes.push({ severity: 'fehler', code: 'keine-bandbreite', message: 'Mit den angegebenen Freigabezeiten und Versätzen entsteht keine durchgehende Bandbreite. ' + 'Die Versatzzeiten sind anzupassen oder die Freigabezeiten der Hauptrichtung zu verlängern.', }); } return { cycleTime, speed, intersections: rows, bandwidth: roundTo(bandwidth, 1), bandwidthRatio: roundTo(bandwidthRatio, 3), quality: rateBandwidth(bandwidthRatio), hinrichtung: hin, rueckrichtung, notes, }; } /** Aus Lage und Laenge eines Bogens ein bewertetes Band. */ function bandAus(bogen: { beginn: Seconds; laenge: Seconds }, cycleTime: Seconds): Band { const anteil = cycleTime > 0 ? bogen.laenge / cycleTime : 0; return { beginn: roundTo(modulo(bogen.beginn, cycleTime), 1), breite: roundTo(bogen.laenge, 1), anteil: roundTo(anteil, 3), quality: rateBandwidth(anteil), }; } /** * Laengster zusammenhaengender Bogen, der in allen Fenstern liegt - die * Fenster als Boegen eines Kreises der Laenge tU verstanden. * * Die Schnittmenge mehrerer Kreisboegen kann in mehrere getrennte Boegen * zerfallen; nutzbar ist der laengste, nicht ihre Summe: ein Fahrzeugpulk faehrt * in einem Zug durch. Jeder dieser Boegen beginnt am Anfang eines der Fenster, * deshalb genuegt es, diese Anfaenge als Kandidaten zu pruefen. Ohne * einschraenkendes Fenster ist der ganze Umlauf nutzbar. */ function groessterGemeinsamerBogen( fenster: readonly { readonly start: Seconds; readonly duration: Seconds }[], cycleTime: Seconds, ): { beginn: Seconds; laenge: Seconds } { if (fenster.length === 0) return { beginn: 0, laenge: cycleTime }; let laengster = 0; let beginn = 0; for (const kandidat of fenster) { let laenge = cycleTime; for (const f of fenster) { let abstand = modulo(kandidat.start - f.start, cycleTime); // Ein Rest knapp unterhalb der Umlaufzeit ist ein Rundungsrest von 0. if (abstand > cycleTime - 1e-9) abstand = 0; if (abstand >= f.duration) { laenge = 0; break; } laenge = Math.min(laenge, f.duration - abstand); } if (laenge > laengster) { laengster = laenge; beginn = kandidat.start; } } return { beginn, laenge: Math.min(cycleTime, laengster) }; } /** * Bewertung der Bandbreite. Als brauchbar gilt eine Gruene Welle ueblicherweise * ab etwa einem Viertel der Umlaufzeit. */ function rateBandwidth(ratio: number): CoordinationResult['quality'] { if (ratio <= 0) return 'keine'; if (ratio >= 0.35) return 'gut'; if (ratio >= 0.25) return 'brauchbar'; return 'gering'; } /** Reisezeit zwischen zwei Knotenpunkten. */ export function travelTime(distance: Meters, speed: KilometersPerHour): Seconds { if (!isFiniteNumber(distance) || distance < 0) return 0; if (!isFiniteNumber(speed) || speed <= 0) return Number.POSITIVE_INFINITY; return distance / kmhToMs(speed); } /** Positiver Rest, auch fuer negative Eingaben. */ export function modulo(value: number, m: number): number { if (m <= 0) return 0; return ((value % m) + m) % m; } function emptyResult( cycleTime: Seconds, speed: KilometersPerHour, notes: CalculationNote[], ): CoordinationResult { const leer: Band = { beginn: 0, breite: 0, anteil: 0, quality: 'keine' }; return { cycleTime, speed, intersections: [], bandwidth: 0, bandwidthRatio: 0, quality: 'keine', hinrichtung: leer, rueckrichtung: null, notes, }; } function format(value: number): string { return roundTo(value, 1).toString().replace('.', ','); }