lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import type { Ratio, Seconds } from '../units'; |
| 2 | import { ceilToStep, clamp, isFiniteNumber, roundTo } from '../units'; |
| 3 | import { RILSA_DEFAULTS, type RilsaDefaults } from './constants'; |
| 4 | import type { CalculationNote, CycleTimeInput, CycleTimeMethod, CycleTimeResult } from './types'; |
| 5 | |
| 6 | /** |
| 7 | * Ermittlung der Umlaufzeit. |
| 8 | * |
| 9 | * Vier Verfahren, die tatsaechlich unterschiedliche Ansaetze abbilden: |
| 10 | * |
| 11 | * webster Wartezeitminimal: tU = (1,5 * L + 5) / (1 - Y) |
| 12 | * akcelik Wartezeitminimal, robuster bei hoher Auslastung: |
| 13 | * tU = (1,4 * L + 6) / (1 - Y) |
| 14 | * hbs Kapazitaetsorientiert, auf einen Ziel-Auslastungsgrad x bemessen: |
| 15 | * tU = L / (1 - Y / x) |
| 16 | * hcm Wie hbs, jedoch mit dem kritischen Auslastungsgrad Xc: |
| 17 | * tU = L * Xc / (Xc - Y) |
| 18 | * |
| 19 | * mit L = Summe der Verlustzeiten je Umlauf (Anfahrverluste + Zwischenzeiten) |
| 20 | * Y = Summe der massgebenden Saettigungsgrade der Phasen |
| 21 | * |
| 22 | * Korrekturen gegenueber dem Altbestand: |
| 23 | * |
| 24 | * - Bei Y >= 1 wurde (1 - Y) negativ oder 0. Das Ergebnis war negativ oder |
| 25 | * unendlich und wurde anschliessend stumm auf die Mindestumlaufzeit von 30 s |
| 26 | * geklemmt. Ein uebersaettigter Knoten erschien damit als besonders guenstig |
| 27 | * bemessen. Jetzt wird der Fall erkannt und als Fehler gemeldet. |
| 28 | * - Ohne Verkehrsdaten wurde Y = min(0,75; 0,3 * Phasenanzahl) frei gesetzt. |
| 29 | * Eine Umlaufzeit ohne Verkehrsstaerken ist nicht ermittelbar; das Verfahren |
| 30 | * verlangt jetzt Eingangsdaten und meldet ihr Fehlen. |
| 31 | * - Das "HBS-Verfahren" rechnete L / (1 - 0,85) und ignorierte Y vollstaendig, |
| 32 | * lieferte also stets das 6,67-fache der Verlustzeit. |
| 33 | * - "Akcelik" war Webster mal 1,1 bzw. 1,2 - kein eigenes Verfahren. |
| 34 | * - Die Empfehlung mittelte ein Objekt mit einer Zahl; das Ergebnis war immer |
| 35 | * NaN und wurde als "Empfehlung" angezeigt. |
| 36 | */ |
| 37 | export function computeCycleTime( |
| 38 | method: CycleTimeMethod, |
| 39 | input: CycleTimeInput, |
| 40 | defaults: RilsaDefaults = RILSA_DEFAULTS, |
| 41 | ): CycleTimeResult { |
| 42 | const notes: CalculationNote[] = []; |
| 43 | const step = input.step ?? defaults.cycleTime.step; |
| 44 | const lostTime = sanitizeLostTime(input.lostTime, notes); |
| 45 | const totalFlowRatio = sanitizeFlowRatios(input.criticalFlowRatios, notes); |
| 46 | const minimumCycle = input.minimumCycle ?? defaults.cycleTime.min; |
| 47 | |
| 48 | const infeasible = describeInfeasibility(method, totalFlowRatio, defaults); |
| 49 | if (infeasible) { |
| 50 | notes.push(infeasible); |
| 51 | /* |
| 52 | * KORREKTUR (Fassung 5.4.0, Befund C11): Bis dahin stand hier |
| 53 | * bounded 'maximum' - derselbe Zustand wie bei einer rechenbaren, aber zu |
| 54 | * langen Umlaufzeit. Der Plan trug 120 s wie ein gueltiges Ergebnis, und |
| 55 | * nur die Fehlermeldung daneben sagte, dass es keines ist. Jetzt traegt |
| 56 | * das Ergebnis den eigenen Zustand 'uebersaettigt'; cycleTime bleibt die |
| 57 | * Obergrenze als ERSATZWERT (der Plan braucht eine Zahl), raw bleibt |
| 58 | * unendlich. Jede Ausgabe hat den Ersatzwert als solchen zu kennzeichnen. |
| 59 | */ |
| 60 | return { |
| 61 | method, |
| 62 | cycleTime: defaults.cycleTime.max, |
| 63 | raw: Number.POSITIVE_INFINITY, |
| 64 | totalFlowRatio, |
| 65 | lostTime, |
| 66 | bounded: 'uebersaettigt', |
| 67 | notes, |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | const raw = rawCycleTime(method, lostTime, totalFlowRatio, defaults); |
| 72 | |
| 73 | /* |
| 74 | * DER ERFORDERLICHE UMLAUF - OHNE RASTERUNG. |
| 75 | * |
| 76 | * Bis dahin wurde der GERASTERTE Wert gegen den Hoechstwert geprueft. Damit |
| 77 | * entschied die Rasterung - ein Praxiswert ohne Regelwerksbezug, in der |
| 78 | * Vorgabenverwaltung von 1 bis 10 s frei einstellbar - darueber, ob der |
| 79 | * Pruefbericht "Die Anlage ist mit dieser Phaseneinteilung nicht |
| 80 | * leistungsfaehig" meldet. Gemessen mit demselben rechnerischen Wert |
| 81 | * 119,48 s und dem Hoechstwert 120 s: bei den Rasterungen 1 bis 6, 8 und |
| 82 | * 10 s die Umlaufzeit 120 s ohne Beanstandung, bei 7 und 9 s dieselben |
| 83 | * 120 s mit dem Fehler 'umlaufzeit-ueber-hoechstwert'. Die Aussage |
| 84 | * "nicht leistungsfaehig" ist eine ueber Verkehrsstaerken, Mindestfreigabe- |
| 85 | * und Zwischenzeiten; eine Anzeigerasterung darf sie weder herbeifuehren |
| 86 | * noch abwenden. |
| 87 | * |
| 88 | * MASSGEBEND IST DAS MAXIMUM AUS DREI GROESSEN, nicht `raw` allein: |
| 89 | * `minimumCycle` folgt zwingend aus Mindestfreigabe- und Zwischenzeiten - |
| 90 | * liegt SIE ueber dem Hoechstwert, ist die Anlage wirklich nicht schaltbar, |
| 91 | * und diese Meldung darf nicht verlorengehen. `cycleTime.min` steht mit |
| 92 | * dabei, damit ein widerspruechlicher Bereich (min > max, gemeldet als |
| 93 | * 'umlaufzeit-grenzen-widerspruch' im Planaufbau) hier weiterhin |
| 94 | * beanstandet wird. |
| 95 | * |
| 96 | * SICHERHEITSRICHTUNG: Die Umstellung meldet SELTENER als zuvor, und zwar |
| 97 | * genau im Band zwischen dem groessten Vielfachen der Rasterung unterhalb |
| 98 | * des Hoechstwerts und dem Hoechstwert selbst. In diesem Band deckt der |
| 99 | * ausgegebene Wert - der Hoechstwert - den erforderlichen Umlauf |
| 100 | * vollstaendig ab; die Anlage ist schaltbar, und die entfallene Meldung war |
| 101 | * unzutreffend. Ueberschreitet der erforderliche Umlauf den Hoechstwert |
| 102 | * tatsaechlich, meldet die Pruefung wie zuvor. |
| 103 | * |
| 104 | * DIE RASTERUNG IST KEINE HARTE NEBENBEDINGUNG DES ERGEBNISSES: Die Kappung |
| 105 | * auf den Hoechstwert bricht sie schon immer (120 ist kein Vielfaches von |
| 106 | * 7). Sie wird deshalb der Kappung nachgeordnet, nicht umgekehrt. |
| 107 | */ |
| 108 | const erforderlich = Math.max(raw, minimumCycle, defaults.cycleTime.min); |
| 109 | |
| 110 | let value = ceilToStep(raw, step); |
| 111 | let bounded: CycleTimeResult['bounded'] = 'keine'; |
| 112 | |
| 113 | if (value < minimumCycle) { |
| 114 | // Die aus Mindestfreigabe- und Zwischenzeiten folgende Umlaufzeit ist eine |
| 115 | // harte Untergrenze: kuerzer laesst sich der Umlauf gar nicht schalten. |
| 116 | value = ceilToStep(minimumCycle, step); |
| 117 | bounded = 'mindestumlauf'; |
| 118 | notes.push({ |
| 119 | severity: 'hinweis', |
| 120 | code: 'mindestumlauf-massgebend', |
| 121 | message: |
| 122 | `Der rechnerische Wert ${formatNumber(raw)} s liegt unter der aus Mindestfreigabezeiten und ` + |
| 123 | `Zwischenzeiten folgenden Mindestumlaufzeit von ${formatNumber(minimumCycle)} s. ` + |
| 124 | 'Maßgebend ist die Mindestumlaufzeit.', |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | if (value < defaults.cycleTime.min) { |
| 129 | value = defaults.cycleTime.min; |
| 130 | bounded = 'minimum'; |
| 131 | } |
| 132 | |
| 133 | /* |
| 134 | * Geprueft wird der erforderliche Umlauf, ausgegeben der gerasterte: Die |
| 135 | * Meldung nennt jetzt die Zahl, von der sie spricht. Zuvor stand dort |
| 136 | * `value` - der gerasterte Wert - hinter dem Wort "erforderlich"; bei |
| 137 | * 119,48 s und Rasterung 7 s las der Bearbeiter "Die erforderliche |
| 138 | * Umlaufzeit beträgt 126,0 s", und diese Zahl war in keiner Rechnung |
| 139 | * erforderlich. |
| 140 | */ |
| 141 | if (erforderlich > defaults.cycleTime.max) { |
| 142 | const paar = unterscheidbar(erforderlich, defaults.cycleTime.max); |
| 143 | notes.push({ |
| 144 | severity: 'fehler', |
| 145 | code: 'umlaufzeit-ueber-hoechstwert', |
| 146 | message: |
| 147 | `Die erforderliche Umlaufzeit beträgt ${paar.wert} s und überschreitet den ` + |
| 148 | `Höchstwert von ${paar.grenze} s. Die Anlage ist mit dieser ` + |
| 149 | 'Phaseneinteilung nicht leistungsfähig - Phasenanzahl verringern oder Zufahrt ausbauen.', |
| 150 | }); |
| 151 | bounded = 'maximum'; |
| 152 | } |
| 153 | |
| 154 | /* |
| 155 | * Gekappt wird danach - auch dort, wo allein die Rasterung ueber den |
| 156 | * Hoechstwert hinausgefuehrt hat. `bounded` bleibt in diesem Fall bei dem |
| 157 | * Zustand, den die Schranken vor der Rasterung ergeben haben: 'maximum' |
| 158 | * sagt aus, dass der ERFORDERLICHE Umlauf nicht unter den Hoechstwert |
| 159 | * passt, nicht, dass eine Aufrundung gekuerzt wurde. |
| 160 | */ |
| 161 | if (value > defaults.cycleTime.max) { |
| 162 | value = defaults.cycleTime.max; |
| 163 | } |
| 164 | |
| 165 | if ( |
| 166 | bounded === 'keine' && |
| 167 | (value < defaults.cycleTime.recommendedMin || value > defaults.cycleTime.recommendedMax) |
| 168 | ) { |
| 169 | notes.push({ |
| 170 | severity: 'hinweis', |
| 171 | code: 'umlaufzeit-ausserhalb-regelbereich', |
| 172 | message: |
| 173 | `Die Umlaufzeit ${formatNumber(value)} s liegt außerhalb des üblichen Bereichs von ` + |
| 174 | `${formatNumber(defaults.cycleTime.recommendedMin)} bis ` + |
| 175 | `${formatNumber(defaults.cycleTime.recommendedMax)} s.`, |
| 176 | }); |
| 177 | } |
| 178 | |
| 179 | return { |
| 180 | method, |
| 181 | cycleTime: value, |
| 182 | raw: roundTo(raw, 2), |
| 183 | totalFlowRatio, |
| 184 | lostTime, |
| 185 | bounded, |
| 186 | notes, |
| 187 | }; |
| 188 | } |
| 189 | |
| 190 | function rawCycleTime( |
| 191 | method: CycleTimeMethod, |
| 192 | lostTime: Seconds, |
| 193 | Y: Ratio, |
| 194 | defaults: RilsaDefaults, |
| 195 | ): Seconds { |
| 196 | switch (method) { |
| 197 | case 'webster': |
| 198 | return (1.5 * lostTime + 5) / (1 - Y); |
| 199 | case 'akcelik': |
| 200 | return (1.4 * lostTime + 6) / (1 - Y); |
| 201 | case 'hbs': { |
| 202 | const x = defaults.capacity.targetDegreeOfSaturation; |
| 203 | return (lostTime * x) / (x - Y); |
| 204 | } |
| 205 | case 'hcm': { |
| 206 | const xc = defaults.capacity.maxDegreeOfSaturation; |
| 207 | return (lostTime * xc) / (xc - Y); |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | function describeInfeasibility( |
| 213 | method: CycleTimeMethod, |
| 214 | Y: Ratio, |
| 215 | defaults: RilsaDefaults, |
| 216 | ): CalculationNote | undefined { |
| 217 | const limit = |
| 218 | method === 'hbs' |
| 219 | ? defaults.capacity.targetDegreeOfSaturation |
| 220 | : method === 'hcm' |
| 221 | ? defaults.capacity.maxDegreeOfSaturation |
| 222 | : 1; |
| 223 | |
| 224 | if (Y >= limit) { |
| 225 | return { |
| 226 | severity: 'fehler', |
| 227 | code: 'uebersaettigt', |
| 228 | message: |
| 229 | // "Der Knotenpunkt" waere bei einer Baustellenampel oder einer |
| 230 | // Fussgaengerschutzanlage schlicht falsch - und stuende im Bericht |
| 231 | // unmittelbar unter einer Ueberschrift, die die Anlagenart richtig |
| 232 | // benennt. Die Umlaufzeitrechnung kennt die Anlagenart nicht; sie |
| 233 | // formuliert deshalb neutral. |
| 234 | `Die Summe der maßgebenden Sättigungsgrade beträgt Y = ${formatNumber(Y, 3)} und erreicht ` + |
| 235 | `die Grenze ${formatNumber(limit, 2)}. Die Anlage ist mit dieser Phaseneinteilung ` + |
| 236 | 'übersättigt; eine Umlaufzeit lässt sich nicht ermitteln. Der Plan führt ersatzweise die ' + |
| 237 | `Obergrenze von ${formatNumber(defaults.cycleTime.max, 0)} s - das ist kein Bemessungsergebnis ` + |
| 238 | '(nicht bemessbar, Ersatzwert). Die Verkehrsstärken, die Fahrstreifenaufteilung oder die ' + |
| 239 | 'Phasenfolge sind zu überarbeiten.', |
| 240 | }; |
| 241 | } |
| 242 | return undefined; |
| 243 | } |
| 244 | |
| 245 | function sanitizeLostTime(lostTime: Seconds, notes: CalculationNote[]): Seconds { |
| 246 | if (!isFiniteNumber(lostTime) || lostTime < 0) { |
| 247 | notes.push({ |
| 248 | severity: 'fehler', |
| 249 | code: 'verlustzeit-ungueltig', |
| 250 | message: 'Die Verlustzeit je Umlauf ist kein gültiger Wert. Angesetzt wird 0 s.', |
| 251 | }); |
| 252 | return 0; |
| 253 | } |
| 254 | return lostTime; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Summe der massgebenden Saettigungsgrade Y. |
| 259 | * |
| 260 | * Eine Phase ohne erfasste Verkehrsstaerke kommt als `undefined` an und wird |
| 261 | * gemeldet, statt stillschweigend mit 0 aufsummiert zu werden. Vor der |
| 262 | * Fassung 5.9.0 konnte diese Rechnung die Luecke gar nicht sehen - der Aufrufer |
| 263 | * gab fuer sie die Zahl 0 -, und die Meldung griff nur bei einer leeren Liste. |
| 264 | * Ein zu kleines Y ergibt eine zu kurze Umlaufzeit: die gefaehrliche Richtung, |
| 265 | * weil die Anlage leistungsfaehiger erscheint, als sie ist. |
| 266 | * |
| 267 | * 0 bleibt eine Angabe und wird nicht beanstandet. Eine reine |
| 268 | * Fussgaengerphase traegt keine Saettigungsverkehrsstaerke und hat darum im |
| 269 | * Regelfall das Gewicht 0; sie kommt als 0 an, nicht als `undefined`. |
| 270 | */ |
| 271 | function sanitizeFlowRatios( |
| 272 | ratios: readonly (Ratio | undefined)[], |
| 273 | notes: CalculationNote[], |
| 274 | ): Ratio { |
| 275 | if (ratios.length === 0) { |
| 276 | notes.push({ |
| 277 | severity: 'fehler', |
| 278 | code: 'saettigungsgrade-fehlen', |
| 279 | message: |
| 280 | 'Es liegen keine maßgebenden Sättigungsgrade vor. Ohne Verkehrsstärken lässt sich keine ' + |
| 281 | 'Umlaufzeit ermitteln - bitte Verkehrsstärken je Signalgruppe erfassen.', |
| 282 | }); |
| 283 | return 0; |
| 284 | } |
| 285 | let sum = 0; |
| 286 | let invalid = 0; |
| 287 | const nichtErfasst: string[] = []; |
| 288 | for (const [index, r] of ratios.entries()) { |
| 289 | if (r === undefined) { |
| 290 | // Die Phasen werden ueber ihre Stelle benannt: Diese Rechnung kennt die |
| 291 | // Phasennamen nicht, und "Phase 2" ist dieselbe Bezeichnung, die die |
| 292 | // Freigabezeitverteilung in ihren Meldungen verwendet. |
| 293 | nichtErfasst.push(`Phase ${index + 1}`); |
| 294 | continue; |
| 295 | } |
| 296 | if (!isFiniteNumber(r) || r < 0) { |
| 297 | invalid += 1; |
| 298 | continue; |
| 299 | } |
| 300 | sum += r; |
| 301 | } |
| 302 | if (nichtErfasst.length > 0) { |
| 303 | const mehrere = nichtErfasst.length > 1; |
| 304 | notes.push({ |
| 305 | severity: 'warnung', |
| 306 | code: 'saettigungsgrade-unvollstaendig', |
| 307 | message: |
| 308 | `Für ${nichtErfasst.join(', ')} ist keine Verkehrsstärke erfasst. ` + |
| 309 | `${mehrere ? 'Diese Phasen gehen' : 'Diese Phase geht'} mit dem Sättigungsgrad 0 in die ` + |
| 310 | 'Summe Y ein; die ermittelte Umlaufzeit fällt dadurch zu kurz aus.', |
| 311 | }); |
| 312 | } |
| 313 | if (invalid > 0) { |
| 314 | notes.push({ |
| 315 | severity: 'fehler', |
| 316 | code: 'saettigungsgrad-ungueltig', |
| 317 | message: `${invalid} Sättigungsgrad(e) sind ungültig und wurden nicht berücksichtigt.`, |
| 318 | }); |
| 319 | } |
| 320 | return roundTo(sum, 6); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Die vier Rechenansaetze in fester Aufzaehlungsreihenfolge. |
| 325 | * |
| 326 | * DIE REIHENFOLGE BEDEUTET NICHTS. Sie ist die des Objektliterals in |
| 327 | * `compareCycleTimeMethods` und steht hier nur, damit ueber die Verfahren |
| 328 | * ueberhaupt gelaufen werden kann, ohne `Object.keys` auf den Typ zu werfen. |
| 329 | * Wo sie in eine Ausgabe geraet, gehoert dazugeschrieben, dass sie keinen |
| 330 | * Vorrang ausdrueckt. |
| 331 | * |
| 332 | * AUSGEFUEHRT, damit Anzeige und Ausdruck dieselbe Reihenfolge zeigen wie die |
| 333 | * Rechnung. Zwei von Hand gepflegte Aufzaehlungen derselben vier Verfahren |
| 334 | * liefen bei einem fuenften auseinander. |
| 335 | */ |
| 336 | export const CYCLE_METHODS: readonly CycleTimeMethod[] = ['webster', 'akcelik', 'hbs', 'hcm']; |
| 337 | |
| 338 | /** |
| 339 | * Benennung der Verfahren im Klartext. |
| 340 | * |
| 341 | * Dieselbe Schreibweise wie in der Auswahlliste der Phasenansicht und im |
| 342 | * Ausdruck: Akcelik traegt dort seit jeher das Cedille. Zwei Schreibweisen |
| 343 | * desselben Namens in einer Unterlage lesen sich wie zwei Verfahren. |
| 344 | */ |
| 345 | export const CYCLE_METHOD_LABELS: Readonly<Record<CycleTimeMethod, string>> = { |
| 346 | webster: 'Webster', |
| 347 | akcelik: 'Akçelik', |
| 348 | hbs: 'HBS', |
| 349 | hcm: 'HCM', |
| 350 | }; |
| 351 | |
| 352 | /** |
| 353 | * Fuehrt alle Verfahren aus und stellt sie gegenueber. |
| 354 | * |
| 355 | * MASSGEBEND IST EIN WERT, KEIN VERFAHREN (Fassung 5.28.0). Bis dahin gab diese |
| 356 | * Funktion ein einzelnes `CycleTimeResult` als "Empfehlung" zurueck und suchte |
| 357 | * es mit `Object.values(results).find(r => r.cycleTime === recommendedValue)` |
| 358 | * heraus. Bei Gleichstand entschied damit die Reihenfolge des Objektliterals - |
| 359 | * und der Gleichstand ist hier nicht die Ausnahme, sondern der Regelfall: Alle |
| 360 | * vier Ergebnisse sind auf `cycleTime.step` gerastert (Vorgabe 5 s), zwei |
| 361 | * Verfahren landen deshalb regelmaessig auf derselben Zahl. Das ist genau der |
| 362 | * Fall, den die Hausregel "Ein Gleichstand darf nicht die Feldreihenfolge |
| 363 | * entscheiden" meint; sie griff hier nicht, weil die Funktion keinen Aufrufer |
| 364 | * hat. |
| 365 | * |
| 366 | * Die feinere fachliche Auskunft ist nicht ein anderer Gewinner, sondern die |
| 367 | * Einsicht, dass es keinen gibt: Der empfohlene Wert entsteht aus dem Maximum |
| 368 | * zweier Zielgroessen und muss mit keinem einzelnen Verfahrensergebnis |
| 369 | * zusammenfallen. Ausgegeben werden deshalb der WERT und die Liste der |
| 370 | * Verfahren, die ihn erreichen - moeglicherweise keines. |
| 371 | */ |
| 372 | export interface CycleTimeComparison { |
| 373 | readonly results: Readonly<Record<CycleTimeMethod, CycleTimeResult>>; |
| 374 | /** |
| 375 | * Empfohlener Wert in Sekunden - `null`, wo keiner zu verantworten ist. |
| 376 | * |
| 377 | * `null` heisst: Mindestens ein Verfahren hat die Rechnung als Fehler |
| 378 | * beendet, und damit ist auch die aus allen vieren gebildete Zahl keine |
| 379 | * Auskunft mehr. Der Grund steht in `reason`. |
| 380 | */ |
| 381 | readonly recommendedCycleTime: Seconds | null; |
| 382 | /** |
| 383 | * Verfahren, deren Ergebnis den empfohlenen Wert erreicht. |
| 384 | * |
| 385 | * Die Reihenfolge ist die feste Aufzaehlungsreihenfolge und sagt NICHTS |
| 386 | * ueber einen Vorrang; der erste Eintrag ist kein Gewinner. Leer, wo es |
| 387 | * keinen empfohlenen Wert gibt - und moeglich auch dort, wo es einen gibt, |
| 388 | * weil der Wert aus zwei Zielgroessen gebildet wird und mit keinem |
| 389 | * Einzelergebnis zusammenfallen muss. |
| 390 | */ |
| 391 | readonly recommendedMethods: readonly CycleTimeMethod[]; |
| 392 | readonly reason: string; |
| 393 | } |
| 394 | |
| 395 | export function compareCycleTimeMethods( |
| 396 | input: CycleTimeInput, |
| 397 | defaults: RilsaDefaults = RILSA_DEFAULTS, |
| 398 | ): CycleTimeComparison { |
| 399 | const results = { |
| 400 | webster: computeCycleTime('webster', input, defaults), |
| 401 | akcelik: computeCycleTime('akcelik', input, defaults), |
| 402 | hbs: computeCycleTime('hbs', input, defaults), |
| 403 | hcm: computeCycleTime('hcm', input, defaults), |
| 404 | } as const; |
| 405 | |
| 406 | /* |
| 407 | * JEDER Fehler zaehlt, nicht nur die Uebersaettigung. |
| 408 | * |
| 409 | * Bis dahin fragte diese Stelle allein nach dem Kode 'uebersaettigt'. Der |
| 410 | * zweite Fehlerkode der Rechnung, 'umlaufzeit-ueber-hoechstwert', traegt |
| 411 | * dieselbe Schwere und fiel durch. Die Folge war die gefaehrlichste Ausgabe, |
| 412 | * die diese Funktion machen kann: Bei einer Verlustzeit von 40 s und |
| 413 | * Y = 0,80 haengen alle vier Verfahren am Hoechstwert von 120 s, keines |
| 414 | * meldet Uebersaettigung - und die Empfehlung lautete "Empfohlen wird |
| 415 | * 120,0 s", waehrend jedes einzelne Verfahren daneben meldete, die Anlage |
| 416 | * sei mit dieser Phaseneinteilung nicht leistungsfaehig. |
| 417 | * |
| 418 | * WARUM AUCH EIN EINZELNES BEANSTANDETES VERFAHREN GENUEGT: Der empfohlene |
| 419 | * Wert wird unten aus `capacityDriven` und `delayOptimal` gebildet, und |
| 420 | * beide sind Extremwerte ueber je zwei Verfahren. Sobald EIN Ergebnis ein |
| 421 | * Ersatzwert ist - eine uebersaettigte Rechnung traegt den Hoechstwert, eine |
| 422 | * gekappte ebenfalls -, geht dieser Ersatzwert in das Maximum ein. Die |
| 423 | * gebildete Zahl saehe gueltig aus und deckte den erforderlichen Umlauf |
| 424 | * nicht. Keine Auskunft ist hier die sichere Richtung. |
| 425 | */ |
| 426 | const beanstandet = CYCLE_METHODS.filter((method) => |
| 427 | results[method].notes.some((note) => note.severity === 'fehler'), |
| 428 | ); |
| 429 | |
| 430 | if (beanstandet.length > 0) { |
| 431 | return { |
| 432 | results, |
| 433 | /* |
| 434 | * KEIN GUELTIG AUSSEHENDES ERGEBNIS IM FEHLERFALL (Fassung 5.28.0). Hier |
| 435 | * stand `recommended: results.webster` - ein vollstaendiges Ergebnis mit |
| 436 | * einer Zahl, in einem Feld, von dem der Begruendungssatz unmittelbar |
| 437 | * daneben sagte, es gebe keines. |
| 438 | */ |
| 439 | recommendedCycleTime: null, |
| 440 | recommendedMethods: [], |
| 441 | reason: begruendungOhneEmpfehlung(results, beanstandet, defaults), |
| 442 | }; |
| 443 | } |
| 444 | |
| 445 | // Kapazitaetsbedarf ist die Untergrenze, Wartezeitoptimum die Zielgroesse. |
| 446 | const capacityDriven = Math.max(results.hbs.cycleTime, results.hcm.cycleTime); |
| 447 | const delayOptimal = Math.min(results.webster.cycleTime, results.akcelik.cycleTime); |
| 448 | const recommendedValue = clamp( |
| 449 | Math.max(capacityDriven, delayOptimal), |
| 450 | defaults.cycleTime.min, |
| 451 | defaults.cycleTime.max, |
| 452 | ); |
| 453 | |
| 454 | const erreichtVon = CYCLE_METHODS.filter( |
| 455 | (method) => results[method].cycleTime === recommendedValue, |
| 456 | ); |
| 457 | |
| 458 | return { |
| 459 | results, |
| 460 | recommendedCycleTime: recommendedValue, |
| 461 | recommendedMethods: erreichtVon, |
| 462 | reason: |
| 463 | `Empfohlen wird ${formatNumber(recommendedValue)} s. Der kapazitätsorientierte Bedarf ` + |
| 464 | `liegt bei ${formatNumber(capacityDriven)} s, das Wartezeitoptimum bei ` + |
| 465 | `${formatNumber(delayOptimal)} s; maßgebend ist der größere der beiden Werte. ` + |
| 466 | (erreichtVon.length === 0 |
| 467 | ? 'Kein einzelnes Verfahren erreicht diesen Wert; er folgt aus der Verbindung beider ' + |
| 468 | 'Zielgrößen.' |
| 469 | : `Erreicht wird dieser Wert von ${aufzaehlung(erreichtVon)}` + |
| 470 | (erreichtVon.length === 1 |
| 471 | ? '.' |
| 472 | : ' - die Aufzählung steht in fester Reihenfolge und drückt keinen Vorrang aus.')), |
| 473 | }; |
| 474 | } |
| 475 | |
| 476 | /** Die Verfahren als Aufzaehlung im Klartext, in fester Reihenfolge. */ |
| 477 | function aufzaehlung(methods: readonly CycleTimeMethod[]): string { |
| 478 | const namen = methods.map((method) => CYCLE_METHOD_LABELS[method]); |
| 479 | if (namen.length <= 1) return namen.join(''); |
| 480 | return `${namen.slice(0, -1).join(', ')} und ${namen[namen.length - 1] ?? ''}`; |
| 481 | } |
| 482 | |
| 483 | /** |
| 484 | * Warum es keine Empfehlung gibt - mit den Gruenden, die wirklich aufgetreten |
| 485 | * sind, und den Verfahren, die sie melden. |
| 486 | * |
| 487 | * Die Gruende werden einmal genannt, auch wenn mehrere Verfahren denselben |
| 488 | * melden: Vier gleichlautende Saetze hintereinander sagen nicht mehr als |
| 489 | * einer. |
| 490 | */ |
| 491 | function begruendungOhneEmpfehlung( |
| 492 | results: Readonly<Record<CycleTimeMethod, CycleTimeResult>>, |
| 493 | beanstandet: readonly CycleTimeMethod[], |
| 494 | defaults: RilsaDefaults, |
| 495 | ): string { |
| 496 | const gruende: string[] = []; |
| 497 | for (const method of beanstandet) { |
| 498 | for (const note of results[method].notes) { |
| 499 | if (note.severity !== 'fehler') continue; |
| 500 | const text = grundText(note, defaults); |
| 501 | if (!gruende.includes(text)) gruende.push(text); |
| 502 | } |
| 503 | } |
| 504 | const wer = |
| 505 | beanstandet.length === CYCLE_METHODS.length |
| 506 | ? 'Beanstandet sind alle vier Verfahren' |
| 507 | : `Beanstandet ${beanstandet.length === 1 ? 'ist' : 'sind'} ${aufzaehlung(beanstandet)}`; |
| 508 | return `Es wird keine Umlaufzeit empfohlen. ${gruende.join(' ')} ${wer}.`; |
| 509 | } |
| 510 | |
| 511 | /** Ein Fehlerkode der Umlaufzeitrechnung als kurzer Satz. */ |
| 512 | function grundText(note: CalculationNote, defaults: RilsaDefaults): string { |
| 513 | switch (note.code) { |
| 514 | case 'uebersaettigt': |
| 515 | return 'Die Anlage ist mit dieser Phaseneinteilung übersättigt.'; |
| 516 | case 'umlaufzeit-ueber-hoechstwert': |
| 517 | return ( |
| 518 | 'Der erforderliche Umlauf überschreitet den Höchstwert von ' + |
| 519 | `${formatNumber(defaults.cycleTime.max, 0)} s.` |
| 520 | ); |
| 521 | case 'verlustzeit-ungueltig': |
| 522 | return 'Die Verlustzeit je Umlauf ist kein gültiger Wert.'; |
| 523 | case 'saettigungsgrade-fehlen': |
| 524 | return 'Es liegen keine maßgebenden Sättigungsgrade vor.'; |
| 525 | case 'saettigungsgrad-ungueltig': |
| 526 | return 'Mindestens ein Sättigungsgrad ist ungültig.'; |
| 527 | default: |
| 528 | // Ein neuer Fehlerkode soll den Satz nicht verschlucken, sondern seine |
| 529 | // eigene Meldung mitbringen. |
| 530 | return note.message; |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Mindestumlaufzeit, die sich zwingend aus der Phasenfolge ergibt: |
| 536 | * Summe der Mindestfreigabezeiten der massgebenden Signalgruppen zuzueglich |
| 537 | * aller Phasenuebergangszeiten. |
| 538 | */ |
| 539 | export function minimumCycleTime( |
| 540 | minGreenPerPhase: readonly Seconds[], |
| 541 | phaseTransitionTimes: readonly Seconds[], |
| 542 | ): Seconds { |
| 543 | const green = minGreenPerPhase.reduce( |
| 544 | (sum, t) => sum + (isFiniteNumber(t) ? Math.max(0, t) : 0), |
| 545 | 0, |
| 546 | ); |
| 547 | const transitions = phaseTransitionTimes.reduce( |
| 548 | (sum, t) => sum + (isFiniteNumber(t) ? Math.max(0, t) : 0), |
| 549 | 0, |
| 550 | ); |
| 551 | return green + transitions; |
| 552 | } |
| 553 | |
| 554 | function formatNumber(value: number, decimals = 1): string { |
| 555 | if (!Number.isFinite(value)) return 'unendlich'; |
| 556 | return roundTo(value, decimals).toFixed(decimals).replace('.', ','); |
| 557 | } |
| 558 | |
| 559 | /** |
| 560 | * Zwei Zahlen so schreiben, dass der Unterschied zwischen ihnen sichtbar |
| 561 | * bleibt. |
| 562 | * |
| 563 | * Seit der erforderliche - ungerasterte - Umlauf gegen den Hoechstwert |
| 564 | * geprueft wird, koennen die beiden Zahlen beliebig dicht beieinanderliegen: |
| 565 | * 120,01 s gegen 120 s ergaeben mit einer Nachkommastelle den Satz "beträgt |
| 566 | * 120,0 s und überschreitet den Höchstwert von 120,0 s", der sich selbst |
| 567 | * widerspricht. Zuvor konnte das nicht vorkommen - dort stand der gerasterte |
| 568 | * Wert, und der lag stets mindestens eine Rasterstufe darueber. |
| 569 | * |
| 570 | * Ist der Unterschied auch mit drei Stellen nicht darstellbar, sagt der Text |
| 571 | * das, statt eine Zahl zu erfinden. |
| 572 | */ |
| 573 | function unterscheidbar(wert: number, grenze: number): { wert: string; grenze: string } { |
| 574 | for (const stellen of [1, 2, 3]) { |
| 575 | const a = formatNumber(wert, stellen); |
| 576 | const b = formatNumber(grenze, stellen); |
| 577 | if (a !== b) return { wert: a, grenze: b }; |
| 578 | } |
| 579 | return { wert: `mehr als ${formatNumber(grenze)}`, grenze: formatNumber(grenze) }; |
| 580 | } |