import type { Ratio, Seconds } from '../units'; import { ceilToStep, clamp, isFiniteNumber, roundTo } from '../units'; import { RILSA_DEFAULTS, type RilsaDefaults } from './constants'; import type { CalculationNote, CycleTimeInput, CycleTimeMethod, CycleTimeResult } from './types'; /** * Ermittlung der Umlaufzeit. * * Vier Verfahren, die tatsaechlich unterschiedliche Ansaetze abbilden: * * webster Wartezeitminimal: tU = (1,5 * L + 5) / (1 - Y) * akcelik Wartezeitminimal, robuster bei hoher Auslastung: * tU = (1,4 * L + 6) / (1 - Y) * hbs Kapazitaetsorientiert, auf einen Ziel-Auslastungsgrad x bemessen: * tU = L / (1 - Y / x) * hcm Wie hbs, jedoch mit dem kritischen Auslastungsgrad Xc: * tU = L * Xc / (Xc - Y) * * mit L = Summe der Verlustzeiten je Umlauf (Anfahrverluste + Zwischenzeiten) * Y = Summe der massgebenden Saettigungsgrade der Phasen * * Korrekturen gegenueber dem Altbestand: * * - Bei Y >= 1 wurde (1 - Y) negativ oder 0. Das Ergebnis war negativ oder * unendlich und wurde anschliessend stumm auf die Mindestumlaufzeit von 30 s * geklemmt. Ein uebersaettigter Knoten erschien damit als besonders guenstig * bemessen. Jetzt wird der Fall erkannt und als Fehler gemeldet. * - Ohne Verkehrsdaten wurde Y = min(0,75; 0,3 * Phasenanzahl) frei gesetzt. * Eine Umlaufzeit ohne Verkehrsstaerken ist nicht ermittelbar; das Verfahren * verlangt jetzt Eingangsdaten und meldet ihr Fehlen. * - Das "HBS-Verfahren" rechnete L / (1 - 0,85) und ignorierte Y vollstaendig, * lieferte also stets das 6,67-fache der Verlustzeit. * - "Akcelik" war Webster mal 1,1 bzw. 1,2 - kein eigenes Verfahren. * - Die Empfehlung mittelte ein Objekt mit einer Zahl; das Ergebnis war immer * NaN und wurde als "Empfehlung" angezeigt. */ export function computeCycleTime( method: CycleTimeMethod, input: CycleTimeInput, defaults: RilsaDefaults = RILSA_DEFAULTS, ): CycleTimeResult { const notes: CalculationNote[] = []; const step = input.step ?? defaults.cycleTime.step; const lostTime = sanitizeLostTime(input.lostTime, notes); const totalFlowRatio = sanitizeFlowRatios(input.criticalFlowRatios, notes); const minimumCycle = input.minimumCycle ?? defaults.cycleTime.min; const infeasible = describeInfeasibility(method, totalFlowRatio, defaults); if (infeasible) { notes.push(infeasible); /* * KORREKTUR (Fassung 5.4.0, Befund C11): Bis dahin stand hier * bounded 'maximum' - derselbe Zustand wie bei einer rechenbaren, aber zu * langen Umlaufzeit. Der Plan trug 120 s wie ein gueltiges Ergebnis, und * nur die Fehlermeldung daneben sagte, dass es keines ist. Jetzt traegt * das Ergebnis den eigenen Zustand 'uebersaettigt'; cycleTime bleibt die * Obergrenze als ERSATZWERT (der Plan braucht eine Zahl), raw bleibt * unendlich. Jede Ausgabe hat den Ersatzwert als solchen zu kennzeichnen. */ return { method, cycleTime: defaults.cycleTime.max, raw: Number.POSITIVE_INFINITY, totalFlowRatio, lostTime, bounded: 'uebersaettigt', notes, }; } const raw = rawCycleTime(method, lostTime, totalFlowRatio, defaults); /* * DER ERFORDERLICHE UMLAUF - OHNE RASTERUNG. * * Bis dahin wurde der GERASTERTE Wert gegen den Hoechstwert geprueft. Damit * entschied die Rasterung - ein Praxiswert ohne Regelwerksbezug, in der * Vorgabenverwaltung von 1 bis 10 s frei einstellbar - darueber, ob der * Pruefbericht "Die Anlage ist mit dieser Phaseneinteilung nicht * leistungsfaehig" meldet. Gemessen mit demselben rechnerischen Wert * 119,48 s und dem Hoechstwert 120 s: bei den Rasterungen 1 bis 6, 8 und * 10 s die Umlaufzeit 120 s ohne Beanstandung, bei 7 und 9 s dieselben * 120 s mit dem Fehler 'umlaufzeit-ueber-hoechstwert'. Die Aussage * "nicht leistungsfaehig" ist eine ueber Verkehrsstaerken, Mindestfreigabe- * und Zwischenzeiten; eine Anzeigerasterung darf sie weder herbeifuehren * noch abwenden. * * MASSGEBEND IST DAS MAXIMUM AUS DREI GROESSEN, nicht `raw` allein: * `minimumCycle` folgt zwingend aus Mindestfreigabe- und Zwischenzeiten - * liegt SIE ueber dem Hoechstwert, ist die Anlage wirklich nicht schaltbar, * und diese Meldung darf nicht verlorengehen. `cycleTime.min` steht mit * dabei, damit ein widerspruechlicher Bereich (min > max, gemeldet als * 'umlaufzeit-grenzen-widerspruch' im Planaufbau) hier weiterhin * beanstandet wird. * * SICHERHEITSRICHTUNG: Die Umstellung meldet SELTENER als zuvor, und zwar * genau im Band zwischen dem groessten Vielfachen der Rasterung unterhalb * des Hoechstwerts und dem Hoechstwert selbst. In diesem Band deckt der * ausgegebene Wert - der Hoechstwert - den erforderlichen Umlauf * vollstaendig ab; die Anlage ist schaltbar, und die entfallene Meldung war * unzutreffend. Ueberschreitet der erforderliche Umlauf den Hoechstwert * tatsaechlich, meldet die Pruefung wie zuvor. * * DIE RASTERUNG IST KEINE HARTE NEBENBEDINGUNG DES ERGEBNISSES: Die Kappung * auf den Hoechstwert bricht sie schon immer (120 ist kein Vielfaches von * 7). Sie wird deshalb der Kappung nachgeordnet, nicht umgekehrt. */ const erforderlich = Math.max(raw, minimumCycle, defaults.cycleTime.min); let value = ceilToStep(raw, step); let bounded: CycleTimeResult['bounded'] = 'keine'; if (value < minimumCycle) { // Die aus Mindestfreigabe- und Zwischenzeiten folgende Umlaufzeit ist eine // harte Untergrenze: kuerzer laesst sich der Umlauf gar nicht schalten. value = ceilToStep(minimumCycle, step); bounded = 'mindestumlauf'; notes.push({ severity: 'hinweis', code: 'mindestumlauf-massgebend', message: `Der rechnerische Wert ${formatNumber(raw)} s liegt unter der aus Mindestfreigabezeiten und ` + `Zwischenzeiten folgenden Mindestumlaufzeit von ${formatNumber(minimumCycle)} s. ` + 'Maßgebend ist die Mindestumlaufzeit.', }); } if (value < defaults.cycleTime.min) { value = defaults.cycleTime.min; bounded = 'minimum'; } /* * Geprueft wird der erforderliche Umlauf, ausgegeben der gerasterte: Die * Meldung nennt jetzt die Zahl, von der sie spricht. Zuvor stand dort * `value` - der gerasterte Wert - hinter dem Wort "erforderlich"; bei * 119,48 s und Rasterung 7 s las der Bearbeiter "Die erforderliche * Umlaufzeit beträgt 126,0 s", und diese Zahl war in keiner Rechnung * erforderlich. */ if (erforderlich > defaults.cycleTime.max) { const paar = unterscheidbar(erforderlich, defaults.cycleTime.max); notes.push({ severity: 'fehler', code: 'umlaufzeit-ueber-hoechstwert', message: `Die erforderliche Umlaufzeit beträgt ${paar.wert} s und überschreitet den ` + `Höchstwert von ${paar.grenze} s. Die Anlage ist mit dieser ` + 'Phaseneinteilung nicht leistungsfähig - Phasenanzahl verringern oder Zufahrt ausbauen.', }); bounded = 'maximum'; } /* * Gekappt wird danach - auch dort, wo allein die Rasterung ueber den * Hoechstwert hinausgefuehrt hat. `bounded` bleibt in diesem Fall bei dem * Zustand, den die Schranken vor der Rasterung ergeben haben: 'maximum' * sagt aus, dass der ERFORDERLICHE Umlauf nicht unter den Hoechstwert * passt, nicht, dass eine Aufrundung gekuerzt wurde. */ if (value > defaults.cycleTime.max) { value = defaults.cycleTime.max; } if ( bounded === 'keine' && (value < defaults.cycleTime.recommendedMin || value > defaults.cycleTime.recommendedMax) ) { notes.push({ severity: 'hinweis', code: 'umlaufzeit-ausserhalb-regelbereich', message: `Die Umlaufzeit ${formatNumber(value)} s liegt außerhalb des üblichen Bereichs von ` + `${formatNumber(defaults.cycleTime.recommendedMin)} bis ` + `${formatNumber(defaults.cycleTime.recommendedMax)} s.`, }); } return { method, cycleTime: value, raw: roundTo(raw, 2), totalFlowRatio, lostTime, bounded, notes, }; } function rawCycleTime( method: CycleTimeMethod, lostTime: Seconds, Y: Ratio, defaults: RilsaDefaults, ): Seconds { switch (method) { case 'webster': return (1.5 * lostTime + 5) / (1 - Y); case 'akcelik': return (1.4 * lostTime + 6) / (1 - Y); case 'hbs': { const x = defaults.capacity.targetDegreeOfSaturation; return (lostTime * x) / (x - Y); } case 'hcm': { const xc = defaults.capacity.maxDegreeOfSaturation; return (lostTime * xc) / (xc - Y); } } } function describeInfeasibility( method: CycleTimeMethod, Y: Ratio, defaults: RilsaDefaults, ): CalculationNote | undefined { const limit = method === 'hbs' ? defaults.capacity.targetDegreeOfSaturation : method === 'hcm' ? defaults.capacity.maxDegreeOfSaturation : 1; if (Y >= limit) { return { severity: 'fehler', code: 'uebersaettigt', message: // "Der Knotenpunkt" waere bei einer Baustellenampel oder einer // Fussgaengerschutzanlage schlicht falsch - und stuende im Bericht // unmittelbar unter einer Ueberschrift, die die Anlagenart richtig // benennt. Die Umlaufzeitrechnung kennt die Anlagenart nicht; sie // formuliert deshalb neutral. `Die Summe der maßgebenden Sättigungsgrade beträgt Y = ${formatNumber(Y, 3)} und erreicht ` + `die Grenze ${formatNumber(limit, 2)}. Die Anlage ist mit dieser Phaseneinteilung ` + 'übersättigt; eine Umlaufzeit lässt sich nicht ermitteln. Der Plan führt ersatzweise die ' + `Obergrenze von ${formatNumber(defaults.cycleTime.max, 0)} s - das ist kein Bemessungsergebnis ` + '(nicht bemessbar, Ersatzwert). Die Verkehrsstärken, die Fahrstreifenaufteilung oder die ' + 'Phasenfolge sind zu überarbeiten.', }; } return undefined; } function sanitizeLostTime(lostTime: Seconds, notes: CalculationNote[]): Seconds { if (!isFiniteNumber(lostTime) || lostTime < 0) { notes.push({ severity: 'fehler', code: 'verlustzeit-ungueltig', message: 'Die Verlustzeit je Umlauf ist kein gültiger Wert. Angesetzt wird 0 s.', }); return 0; } return lostTime; } /** * Summe der massgebenden Saettigungsgrade Y. * * Eine Phase ohne erfasste Verkehrsstaerke kommt als `undefined` an und wird * gemeldet, statt stillschweigend mit 0 aufsummiert zu werden. Vor der * Fassung 5.9.0 konnte diese Rechnung die Luecke gar nicht sehen - der Aufrufer * gab fuer sie die Zahl 0 -, und die Meldung griff nur bei einer leeren Liste. * Ein zu kleines Y ergibt eine zu kurze Umlaufzeit: die gefaehrliche Richtung, * weil die Anlage leistungsfaehiger erscheint, als sie ist. * * 0 bleibt eine Angabe und wird nicht beanstandet. Eine reine * Fussgaengerphase traegt keine Saettigungsverkehrsstaerke und hat darum im * Regelfall das Gewicht 0; sie kommt als 0 an, nicht als `undefined`. */ function sanitizeFlowRatios( ratios: readonly (Ratio | undefined)[], notes: CalculationNote[], ): Ratio { if (ratios.length === 0) { notes.push({ severity: 'fehler', code: 'saettigungsgrade-fehlen', message: 'Es liegen keine maßgebenden Sättigungsgrade vor. Ohne Verkehrsstärken lässt sich keine ' + 'Umlaufzeit ermitteln - bitte Verkehrsstärken je Signalgruppe erfassen.', }); return 0; } let sum = 0; let invalid = 0; const nichtErfasst: string[] = []; for (const [index, r] of ratios.entries()) { if (r === undefined) { // Die Phasen werden ueber ihre Stelle benannt: Diese Rechnung kennt die // Phasennamen nicht, und "Phase 2" ist dieselbe Bezeichnung, die die // Freigabezeitverteilung in ihren Meldungen verwendet. nichtErfasst.push(`Phase ${index + 1}`); continue; } if (!isFiniteNumber(r) || r < 0) { invalid += 1; continue; } sum += r; } if (nichtErfasst.length > 0) { const mehrere = nichtErfasst.length > 1; notes.push({ severity: 'warnung', code: 'saettigungsgrade-unvollstaendig', message: `Für ${nichtErfasst.join(', ')} ist keine Verkehrsstärke erfasst. ` + `${mehrere ? 'Diese Phasen gehen' : 'Diese Phase geht'} mit dem Sättigungsgrad 0 in die ` + 'Summe Y ein; die ermittelte Umlaufzeit fällt dadurch zu kurz aus.', }); } if (invalid > 0) { notes.push({ severity: 'fehler', code: 'saettigungsgrad-ungueltig', message: `${invalid} Sättigungsgrad(e) sind ungültig und wurden nicht berücksichtigt.`, }); } return roundTo(sum, 6); } /** * Die vier Rechenansaetze in fester Aufzaehlungsreihenfolge. * * DIE REIHENFOLGE BEDEUTET NICHTS. Sie ist die des Objektliterals in * `compareCycleTimeMethods` und steht hier nur, damit ueber die Verfahren * ueberhaupt gelaufen werden kann, ohne `Object.keys` auf den Typ zu werfen. * Wo sie in eine Ausgabe geraet, gehoert dazugeschrieben, dass sie keinen * Vorrang ausdrueckt. * * AUSGEFUEHRT, damit Anzeige und Ausdruck dieselbe Reihenfolge zeigen wie die * Rechnung. Zwei von Hand gepflegte Aufzaehlungen derselben vier Verfahren * liefen bei einem fuenften auseinander. */ export const CYCLE_METHODS: readonly CycleTimeMethod[] = ['webster', 'akcelik', 'hbs', 'hcm']; /** * Benennung der Verfahren im Klartext. * * Dieselbe Schreibweise wie in der Auswahlliste der Phasenansicht und im * Ausdruck: Akcelik traegt dort seit jeher das Cedille. Zwei Schreibweisen * desselben Namens in einer Unterlage lesen sich wie zwei Verfahren. */ export const CYCLE_METHOD_LABELS: Readonly> = { webster: 'Webster', akcelik: 'Akçelik', hbs: 'HBS', hcm: 'HCM', }; /** * Fuehrt alle Verfahren aus und stellt sie gegenueber. * * MASSGEBEND IST EIN WERT, KEIN VERFAHREN (Fassung 5.28.0). Bis dahin gab diese * Funktion ein einzelnes `CycleTimeResult` als "Empfehlung" zurueck und suchte * es mit `Object.values(results).find(r => r.cycleTime === recommendedValue)` * heraus. Bei Gleichstand entschied damit die Reihenfolge des Objektliterals - * und der Gleichstand ist hier nicht die Ausnahme, sondern der Regelfall: Alle * vier Ergebnisse sind auf `cycleTime.step` gerastert (Vorgabe 5 s), zwei * Verfahren landen deshalb regelmaessig auf derselben Zahl. Das ist genau der * Fall, den die Hausregel "Ein Gleichstand darf nicht die Feldreihenfolge * entscheiden" meint; sie griff hier nicht, weil die Funktion keinen Aufrufer * hat. * * Die feinere fachliche Auskunft ist nicht ein anderer Gewinner, sondern die * Einsicht, dass es keinen gibt: Der empfohlene Wert entsteht aus dem Maximum * zweier Zielgroessen und muss mit keinem einzelnen Verfahrensergebnis * zusammenfallen. Ausgegeben werden deshalb der WERT und die Liste der * Verfahren, die ihn erreichen - moeglicherweise keines. */ export interface CycleTimeComparison { readonly results: Readonly>; /** * Empfohlener Wert in Sekunden - `null`, wo keiner zu verantworten ist. * * `null` heisst: Mindestens ein Verfahren hat die Rechnung als Fehler * beendet, und damit ist auch die aus allen vieren gebildete Zahl keine * Auskunft mehr. Der Grund steht in `reason`. */ readonly recommendedCycleTime: Seconds | null; /** * Verfahren, deren Ergebnis den empfohlenen Wert erreicht. * * Die Reihenfolge ist die feste Aufzaehlungsreihenfolge und sagt NICHTS * ueber einen Vorrang; der erste Eintrag ist kein Gewinner. Leer, wo es * keinen empfohlenen Wert gibt - und moeglich auch dort, wo es einen gibt, * weil der Wert aus zwei Zielgroessen gebildet wird und mit keinem * Einzelergebnis zusammenfallen muss. */ readonly recommendedMethods: readonly CycleTimeMethod[]; readonly reason: string; } export function compareCycleTimeMethods( input: CycleTimeInput, defaults: RilsaDefaults = RILSA_DEFAULTS, ): CycleTimeComparison { const results = { webster: computeCycleTime('webster', input, defaults), akcelik: computeCycleTime('akcelik', input, defaults), hbs: computeCycleTime('hbs', input, defaults), hcm: computeCycleTime('hcm', input, defaults), } as const; /* * JEDER Fehler zaehlt, nicht nur die Uebersaettigung. * * Bis dahin fragte diese Stelle allein nach dem Kode 'uebersaettigt'. Der * zweite Fehlerkode der Rechnung, 'umlaufzeit-ueber-hoechstwert', traegt * dieselbe Schwere und fiel durch. Die Folge war die gefaehrlichste Ausgabe, * die diese Funktion machen kann: Bei einer Verlustzeit von 40 s und * Y = 0,80 haengen alle vier Verfahren am Hoechstwert von 120 s, keines * meldet Uebersaettigung - und die Empfehlung lautete "Empfohlen wird * 120,0 s", waehrend jedes einzelne Verfahren daneben meldete, die Anlage * sei mit dieser Phaseneinteilung nicht leistungsfaehig. * * WARUM AUCH EIN EINZELNES BEANSTANDETES VERFAHREN GENUEGT: Der empfohlene * Wert wird unten aus `capacityDriven` und `delayOptimal` gebildet, und * beide sind Extremwerte ueber je zwei Verfahren. Sobald EIN Ergebnis ein * Ersatzwert ist - eine uebersaettigte Rechnung traegt den Hoechstwert, eine * gekappte ebenfalls -, geht dieser Ersatzwert in das Maximum ein. Die * gebildete Zahl saehe gueltig aus und deckte den erforderlichen Umlauf * nicht. Keine Auskunft ist hier die sichere Richtung. */ const beanstandet = CYCLE_METHODS.filter((method) => results[method].notes.some((note) => note.severity === 'fehler'), ); if (beanstandet.length > 0) { return { results, /* * KEIN GUELTIG AUSSEHENDES ERGEBNIS IM FEHLERFALL (Fassung 5.28.0). Hier * stand `recommended: results.webster` - ein vollstaendiges Ergebnis mit * einer Zahl, in einem Feld, von dem der Begruendungssatz unmittelbar * daneben sagte, es gebe keines. */ recommendedCycleTime: null, recommendedMethods: [], reason: begruendungOhneEmpfehlung(results, beanstandet, defaults), }; } // Kapazitaetsbedarf ist die Untergrenze, Wartezeitoptimum die Zielgroesse. const capacityDriven = Math.max(results.hbs.cycleTime, results.hcm.cycleTime); const delayOptimal = Math.min(results.webster.cycleTime, results.akcelik.cycleTime); const recommendedValue = clamp( Math.max(capacityDriven, delayOptimal), defaults.cycleTime.min, defaults.cycleTime.max, ); const erreichtVon = CYCLE_METHODS.filter( (method) => results[method].cycleTime === recommendedValue, ); return { results, recommendedCycleTime: recommendedValue, recommendedMethods: erreichtVon, reason: `Empfohlen wird ${formatNumber(recommendedValue)} s. Der kapazitätsorientierte Bedarf ` + `liegt bei ${formatNumber(capacityDriven)} s, das Wartezeitoptimum bei ` + `${formatNumber(delayOptimal)} s; maßgebend ist der größere der beiden Werte. ` + (erreichtVon.length === 0 ? 'Kein einzelnes Verfahren erreicht diesen Wert; er folgt aus der Verbindung beider ' + 'Zielgrößen.' : `Erreicht wird dieser Wert von ${aufzaehlung(erreichtVon)}` + (erreichtVon.length === 1 ? '.' : ' - die Aufzählung steht in fester Reihenfolge und drückt keinen Vorrang aus.')), }; } /** Die Verfahren als Aufzaehlung im Klartext, in fester Reihenfolge. */ function aufzaehlung(methods: readonly CycleTimeMethod[]): string { const namen = methods.map((method) => CYCLE_METHOD_LABELS[method]); if (namen.length <= 1) return namen.join(''); return `${namen.slice(0, -1).join(', ')} und ${namen[namen.length - 1] ?? ''}`; } /** * Warum es keine Empfehlung gibt - mit den Gruenden, die wirklich aufgetreten * sind, und den Verfahren, die sie melden. * * Die Gruende werden einmal genannt, auch wenn mehrere Verfahren denselben * melden: Vier gleichlautende Saetze hintereinander sagen nicht mehr als * einer. */ function begruendungOhneEmpfehlung( results: Readonly>, beanstandet: readonly CycleTimeMethod[], defaults: RilsaDefaults, ): string { const gruende: string[] = []; for (const method of beanstandet) { for (const note of results[method].notes) { if (note.severity !== 'fehler') continue; const text = grundText(note, defaults); if (!gruende.includes(text)) gruende.push(text); } } const wer = beanstandet.length === CYCLE_METHODS.length ? 'Beanstandet sind alle vier Verfahren' : `Beanstandet ${beanstandet.length === 1 ? 'ist' : 'sind'} ${aufzaehlung(beanstandet)}`; return `Es wird keine Umlaufzeit empfohlen. ${gruende.join(' ')} ${wer}.`; } /** Ein Fehlerkode der Umlaufzeitrechnung als kurzer Satz. */ function grundText(note: CalculationNote, defaults: RilsaDefaults): string { switch (note.code) { case 'uebersaettigt': return 'Die Anlage ist mit dieser Phaseneinteilung übersättigt.'; case 'umlaufzeit-ueber-hoechstwert': return ( 'Der erforderliche Umlauf überschreitet den Höchstwert von ' + `${formatNumber(defaults.cycleTime.max, 0)} s.` ); case 'verlustzeit-ungueltig': return 'Die Verlustzeit je Umlauf ist kein gültiger Wert.'; case 'saettigungsgrade-fehlen': return 'Es liegen keine maßgebenden Sättigungsgrade vor.'; case 'saettigungsgrad-ungueltig': return 'Mindestens ein Sättigungsgrad ist ungültig.'; default: // Ein neuer Fehlerkode soll den Satz nicht verschlucken, sondern seine // eigene Meldung mitbringen. return note.message; } } /** * Mindestumlaufzeit, die sich zwingend aus der Phasenfolge ergibt: * Summe der Mindestfreigabezeiten der massgebenden Signalgruppen zuzueglich * aller Phasenuebergangszeiten. */ export function minimumCycleTime( minGreenPerPhase: readonly Seconds[], phaseTransitionTimes: readonly Seconds[], ): Seconds { const green = minGreenPerPhase.reduce( (sum, t) => sum + (isFiniteNumber(t) ? Math.max(0, t) : 0), 0, ); const transitions = phaseTransitionTimes.reduce( (sum, t) => sum + (isFiniteNumber(t) ? Math.max(0, t) : 0), 0, ); return green + transitions; } function formatNumber(value: number, decimals = 1): string { if (!Number.isFinite(value)) return 'unendlich'; return roundTo(value, decimals).toFixed(decimals).replace('.', ','); } /** * Zwei Zahlen so schreiben, dass der Unterschied zwischen ihnen sichtbar * bleibt. * * Seit der erforderliche - ungerasterte - Umlauf gegen den Hoechstwert * geprueft wird, koennen die beiden Zahlen beliebig dicht beieinanderliegen: * 120,01 s gegen 120 s ergaeben mit einer Nachkommastelle den Satz "beträgt * 120,0 s und überschreitet den Höchstwert von 120,0 s", der sich selbst * widerspricht. Zuvor konnte das nicht vorkommen - dort stand der gerasterte * Wert, und der lag stets mindestens eine Rasterstufe darueber. * * Ist der Unterschied auch mit drei Stellen nicht darstellbar, sagt der Text * das, statt eine Zahl zu erfinden. */ function unterscheidbar(wert: number, grenze: number): { wert: string; grenze: string } { for (const stellen of [1, 2, 3]) { const a = formatNumber(wert, stellen); const b = formatNumber(grenze, stellen); if (a !== b) return { wert: a, grenze: b }; } return { wert: `mehr als ${formatNumber(grenze)}`, grenze: formatNumber(grenze) }; }