lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ src domain rilsa coordination.ts
| 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 | } |