lsa-planer

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

/ src domain rilsa cycle.ts

23,1 KB Rohdatei
src/domain/rilsa/cycle.ts — 580 Zeilen
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 }