import type { Ratio, Seconds } from '../units'; import { isFiniteNumber, roundTo } from '../units'; import type { CalculationNote } from './types'; /** Schranke gegen Rundungsreste der Gleitkommarechnung (Sekunden). */ const EPSILON = 1e-9; /** Eine Phase im Sinne der Freigabezeitverteilung. */ export interface GreenSplitPhase { readonly id: string; /** Massgebender Saettigungsgrad y der Phase; bestimmt den Verteilungsanteil. */ readonly weight: Ratio; /** Mindestfreigabezeit der massgebenden Signalgruppe dieser Phase. */ readonly minGreen: Seconds; /** Optionale Obergrenze der Freigabezeit. */ readonly maxGreen?: Seconds; } export interface GreenSplitResult { readonly phases: readonly { readonly id: string; readonly green: Seconds }[]; /** Fuer Freigabezeiten verfuegbare Zeit: tU abzueglich aller Uebergangszeiten. */ readonly availableGreen: Seconds; /** Summe aller Phasenuebergangszeiten. */ readonly transitionTime: Seconds; /** Ist die Verteilung unter den gegebenen Randbedingungen ueberhaupt moeglich? */ readonly feasible: boolean; readonly notes: readonly CalculationNote[]; } /** * Verteilt die Freigabezeiten auf die Phasen. * * verfuegbar = tU - Summe der Phasenuebergangszeiten * g_i = verfuegbar * y_i / Y, begrenzt auf [minGreen, maxGreen] * * Die Summe der ausgegebenen Freigabezeiten zuzueglich der Uebergangszeiten * ergibt exakt die Umlaufzeit. Genau das leistete der Altbestand nicht: dort * wurden die Anteile einzeln gerundet und anschliessend jeweils auf die * Mindestfreigabezeit angehoben, ohne den entstandenen Ueberhang an anderer * Stelle abzuziehen. Die Summe der Phasendauern konnte dadurch die Umlaufzeit * ueberschreiten - der ausgegebene Signalzeitenplan war nicht schaltbar. * Ausserdem wurden dort die Zwischenzeiten bei der verfuegbaren Zeit gar nicht * abgezogen, sondern durch eine Pauschale von 4 s je Phase ersetzt. * * Massgebend ist dabei das Sekundenraster: Ein Signalzeitenplan schaltet in * ganzen Sekunden. Eine gebrochene Mindestfreigabezeit (12,5 s aus dem Feld * "Mindestfreigabezeit" oder aus einer eingelesenen Projektdatei) wird deshalb * schon vor der Verteilung auf die naechste ganze Sekunde AUFgerundet - das ist * die sichere Richtung - und die dafuer noetige Zeit bei einer anderen Phase * abgezogen. Reicht die Umlaufzeit dafuer nicht, wird das gemeldet * ('umlaufzeit-zu-kurz') und nicht die Umlaufzeit stillschweigend verlaengert. * * Ist die Untergrenze zugleich Obergrenze - eine von Hand vorgegebene * Freigabezeit von 5,5 s setzt minGreen = maxGreen -, geht das Sekundenraster * der Obergrenze vor: geschaltet werden 6 s. Die Mindestfreigabezeit zu * unterschreiten waere die gefaehrliche Richtung, eine halbe Sekunde ueber * einer gebrochenen Obergrenze ist es nicht. */ export function distributeGreenTimes( cycleTime: Seconds, phases: readonly GreenSplitPhase[], transitionTimes: readonly Seconds[], ): GreenSplitResult { const notes: CalculationNote[] = []; const transitionTime = transitionTimes.reduce( (sum, t) => sum + (isFiniteNumber(t) && t > 0 ? t : 0), 0, ); if (phases.length === 0) { notes.push({ severity: 'fehler', code: 'keine-phasen', message: 'Es ist keine Phase definiert; eine Freigabezeitverteilung ist nicht möglich.', }); return { phases: [], availableGreen: 0, transitionTime, feasible: false, notes }; } if (!isFiniteNumber(cycleTime) || cycleTime <= 0) { notes.push({ severity: 'fehler', code: 'umlaufzeit-ungueltig', message: 'Die Umlaufzeit ist kein gültiger Wert größer als 0.', }); return { phases: phases.map((p) => ({ id: p.id, green: 0 })), availableGreen: 0, transitionTime, feasible: false, notes, }; } const availableGreen = cycleTime - transitionTime; // Gerechnet wird gegen die Untergrenzen IM SEKUNDENRASTER: 12,5 s lassen // sich nur einhalten, indem 13 s geschaltet werden. Bei ganzzahligen // Mindestfreigabezeiten - dem Regelfall - aendert das nichts. const minSum = phases.reduce((sum, p) => sum + ganzeSekundenUntergrenze(p.minGreen), 0); /* * Aufgerundet wird, aber nicht stumm. Wer 5,5 s als feste Freigabezeit in * seiner Projektdatei stehen hat, bekommt 6 s geschaltet und muss das im * Rechenweg wiederfinden - sonst steht im Feld eine Zahl und im Plan eine * andere. Fuer die von Hand vorgegebene Zwischenzeit tut das Programm seit * jeher dasselbe (`zwischenzeit-vorgabe-aufgerundet` in signalPlan.ts). * * Ueber die Oberflaeche ist der Fall nicht erreichbar - das Feld "Feste * Freigabezeit" hat Schrittweite 1 -, ueber eine eingelesene Projektdatei * schon. Beim Regelfall der ganzzahligen Vorgabe schweigt die Meldung. */ phases.forEach((p, index) => { const vorgabe = safeMin(p.minGreen); const angesetzt = ganzeSekundenUntergrenze(p.minGreen); if (angesetzt === vorgabe) return; const fest = isFiniteNumber(p.maxGreen) && p.maxGreen === p.minGreen; notes.push({ severity: 'hinweis', code: 'freigabezeit-vorgabe-aufgerundet', message: `Phase ${index + 1}: Die vorgegebene ${fest ? 'feste Freigabezeit' : 'Mindestfreigabezeit'} ` + `${format(vorgabe)} s wird auf ganze Sekunden aufgerundet; angesetzt werden ` + `${angesetzt} s. Ein Signalzeitenplan schaltet in ganzen Sekunden.`, }); }); if (availableGreen < minSum) { notes.push({ severity: 'fehler', code: 'umlaufzeit-zu-kurz', message: `Bei einer Umlaufzeit von ${format(cycleTime)} s verbleiben nach Abzug der Übergangszeiten ` + `von ${format(transitionTime)} s nur ${format(availableGreen)} s für die Freigabezeiten. ` + `Erforderlich sind mindestens ${format(minSum)} s. Die Umlaufzeit ist auf mindestens ` + `${format(minSum + transitionTime)} s zu erhöhen oder die Phasenanzahl zu verringern.`, }); return { phases: phases.map((p) => ({ id: p.id, green: ganzeSekundenUntergrenze(p.minGreen) })), availableGreen, transitionTime, feasible: false, notes, }; } const raw = proportionalSplit(availableGreen, phases, notes); const rounded = roundPreservingSum(raw, availableGreen, phases, notes); return { phases: phases.map((p, index) => ({ id: p.id, green: rounded[index] ?? ganzeSekundenUntergrenze(p.minGreen), })), availableGreen, transitionTime, feasible: true, notes, }; } /** * Proportionale Verteilung mit Beruecksichtigung der Schranken. * Phasen, die an eine Schranke stossen, werden festgehalten; ihr Ueberschuss * bzw. Fehlbetrag wird unter den uebrigen Phasen neu verteilt, bis sich nichts * mehr aendert. */ function proportionalSplit( available: Seconds, phases: readonly GreenSplitPhase[], notes: CalculationNote[], ): number[] { const n = phases.length; const weights = phases.map((p) => (isFiniteNumber(p.weight) && p.weight > 0 ? p.weight : 0)); const weightSum = weights.reduce((a, b) => a + b, 0); if (weightSum === 0) { notes.push({ severity: 'warnung', code: 'keine-gewichtung', message: 'Es liegen keine Verkehrsstärken vor. Die Freigabezeiten werden gleichmäßig verteilt; ' + 'für eine bedarfsgerechte Aufteilung sind Verkehrsstärken je Signalgruppe zu erfassen.', }); weights.fill(1); } const result = new Array(n).fill(0); const fixed = new Array(n).fill(false); let remaining = available; // Hoechstens n Durchlaeufe: je Durchlauf wird mindestens eine Phase fixiert. for (let iteration = 0; iteration <= n; iteration += 1) { const activeWeight = phases.reduce( (sum, _p, i) => (fixed[i] === true ? sum : sum + (weights[i] ?? 0)), 0, ); let changed = false; for (let i = 0; i < n; i += 1) { if (fixed[i] === true) continue; const phase = phases[i]!; const share = activeWeight > 0 ? (remaining * (weights[i] ?? 0)) / activeWeight : remaining / n; const min = safeMin(phase.minGreen); const max = safeMax(phase.maxGreen); if (share < min) { result[i] = min; fixed[i] = true; remaining -= min; changed = true; } else if (share > max) { result[i] = max; fixed[i] = true; remaining -= max; changed = true; } else { result[i] = share; } } if (!changed) break; } /* * Reststuecke entstehen, sobald die gewichtete Verteilung die verfuegbare * Zeit nicht unterbringt: weil eine Phase an ihrer Obergrenze gekappt wurde, * oder weil eine Phase ohne Gewicht - eine reine Fussgaengerphase traegt * keine Saettigungsverkehrsstaerke - stets den Anteil 0 erhaelt und deshalb * an ihrer Mindestfreigabezeit festgehalten wird. */ const assigned = result.reduce((a, b) => a + b, 0); const rest = available - assigned; let leftover = rest; let aufgestockt: readonly number[] = []; if (leftover > 0.5) { const verteilt = restAufPhasenMitSpielraum(result, phases, weights, leftover); aufgestockt = verteilt.aufgestockt; leftover = verteilt.rest; } if (leftover > 0.5) { /* * Jetzt liegt jede Phase an ihrer Hoechstfreigabezeit, und die feste * Umlaufzeit laesst sich damit nicht ausfuellen. Der Rest muss trotzdem * vergeben werden, damit die Summe der Phasendauern die Umlaufzeit trifft - * verteilt wird er ueber die Phasen mit Spielraum, nach Gewichten und ohne * Gewichte zu gleichen Teilen. Welche das sind, sagt der uebernaechste * Absatz. * * BIS 5.22.0 GING ER GANZ AN EINE PHASE, naemlich an die mit dem groessten * Gewicht. Ohne Verkehrsstaerken sind alle Gewichte gleich, und dann * entschied die Reihenfolge: Die erste Phase bekam alles. An einer * einstreifigen Verkehrsfuehrung - zwei Richtungen, derselbe Raeumweg, * dieselbe Zwischenzeit - kam damit ein Plan heraus, dessen beide * Richtungen ohne jeden sachlichen Grund verschieden lang Gruen hatten: * 100 s Umlaufzeit, 8 s Zwischenzeit je Richtung, 40 s Hoechstfreigabezeit * ergaben 44/40 s statt 42/42 s. Aufgefallen ist das beim Nachrechnen eines * angeordneten Plans. Ein Gleichstand der Gewichte ist keine Auskunft * darueber, welche Phase die Zeit bekommen soll; wo nichts die Phasen * unterscheidet, duerfen sie sich auch im Plan nicht unterscheiden. * * PHASEN OHNE SPIELRAUM BLEIBEN AUSSEN VOR, solange es andere gibt. * `minGreen === maxGreen` heisst: Diese Phase hat gar keinen Bereich, ihre * Dauer liegt fest. Das kommt auf zwei Wegen zustande - eine von Hand * eingetragene feste Freigabezeit (dann setzt der Aufrufer beide Schranken * auf die Vorgabe) oder eine Hoechstfreigabezeit, die unter der * Mindestfreigabezeit lag und auf sie angehoben wurde. In beiden Faellen * ist die Zahl das Ergebnis einer ausdruecklichen Angabe und nicht der * obere Rand eines Bereichs; der Rest gehoert an die Phasen, die einen * Bereich haben. * * DAS WAR NICHT DER ERSTE ANLAUF. Zuerst ging der Rest gleichmaessig an * wirklich alle Phasen, und das hat eine Wache umgeworfen: Aus 6 s fest * eingetragen wurden 7 s geschaltet * (tests/domain/freigabezeitVorgabeAufgerundet.test.ts). Erst wenn jede * Phase festliegt, bleibt nichts anderes uebrig, als sie alle zu * ueberschreiten - dann sagt die Meldung auch das. */ const gebunden = phases.map((p) => isFiniteNumber(p.maxGreen) && p.maxGreen === p.minGreen); const alleGebunden = gebunden.every((f) => f); const empfaenger: number[] = []; for (let i = 0; i < n; i += 1) { if (alleGebunden || gebunden[i] !== true) empfaenger.push(i); } const gewichtSumme = empfaenger.reduce((sum, i) => sum + (weights[i] ?? 0), 0); const zuVerteilen = leftover; for (const i of empfaenger) { const anteil = gewichtSumme > 0 ? (zuVerteilen * (weights[i] ?? 0)) / gewichtSumme : zuVerteilen / empfaenger.length; result[i] = (result[i] ?? 0) + anteil; } /* * Die Meldung sagte das Gegenteil dessen, was geschieht. * * "Konnten keiner Phase zugeordnet werden" - zugeordnet werden sie sehr * wohl, unmittelbar darueber. Sie nannte weder die betroffene Phase noch * den ueberschriebenen Wert, und sie stand auf "hinweis". Trifft es eine * Phase mit vorgegebener Freigabezeit, wird diese Vorgabe damit * stillschweigend ueberschrieben: 10 s eingetragen, 28 s geplant. * * Der erste Satz stimmte ausserdem nicht: Er stand auch dann da, wenn eine * Phase an ihrer MINDESTfreigabezeit hing und bis zur Hoechstfreigabezeit * noch hundert Sekunden frei hatte. Der Planer las daraus, seine * Umlaufzeit sei zu lang, obwohl nur die Verteilung versagt hatte. Diese * Phasen sind jetzt vorher bedient; wer hier ankommt, liegt tatsaechlich * ueberall an der Obergrenze. * * SEIT 5.23.0 nennt sie keine einzelne Phase mehr, sondern die, die den * Rest wirklich bekommen, und dazu die Zahl, die weiterhilft: um wieviel * die Umlaufzeit zu kuerzen ist, damit keine Hoechstfreigabezeit * ueberschritten wird. Das ist genau der Rest. */ const namen = empfaenger.map((i) => `Phase ${i + 1}`).join(', '); notes.push({ severity: 'warnung', code: 'freigabezeit-rest', message: `Alle Phasen erreichen ihre Höchstfreigabezeit; ${format(zuVerteilen)} s der Umlaufzeit ` + `bleiben übrig. Sie gehen an ${namen} – nach Verkehrsstärken, und wo keine vorliegen, zu ` + 'gleichen Teilen. ' + (alleGebunden ? 'Keine Phase hat dabei Spielraum; auch eine fest vorgegebene Freigabezeit wird ' + 'überschritten.' : 'Phasen, deren Freigabezeit festliegt, bleiben unangetastet.') + ` Verkürzen Sie die Umlaufzeit um ${format(zuVerteilen)} s, wenn das nicht gewollt ist.`, }); } else if (aufgestockt.length > 0) { /* * Nur, wenn kein Rest geblieben ist: Sonst stuenden zwei Meldungen zur * selben Sache im Bericht, und die Warnung oben sagt bereits, dass jede * Phase an ihrer Obergrenze liegt. * * EIGENE KENNUNG (Fassung 5.9.0): Bis dahin trug dieser Hinweis die * Kennung 'freigabezeit-rest' und erbte damit im Pruefbericht deren * Ueberschrift "Freigabezeit konnte nicht vollständig verteilt werden" und * deren Rat, Freigabezeiten und Umlaufzeit zu pruefen. Hier ist die Zeit * vollstaendig vergeben, und im Regelfall - eine Fussgaengerphase ohne * Verkehrsstaerke - ist nichts zu pruefen. Zwei Sachverhalte unter einer * Kennung ergeben eine Zeile, die sich selbst widerspricht. */ const namen = aufgestockt.map((i) => `Phase ${i + 1}`).join(', '); notes.push({ severity: 'hinweis', code: 'freigabezeit-rest-umgelegt', message: `${format(rest)} s der Umlaufzeit ließen sich nicht nach Verkehrsstärken verteilen: Die ` + 'dafür maßgebenden Phasen liegen an ihrer Höchstfreigabezeit oder führen keine ' + `Verkehrsstärke. Die Zeit geht an ${namen}, wo bis zur Höchstfreigabezeit noch Spielraum ` + 'ist; eine von Hand vorgegebene Freigabezeit bleibt dabei unangetastet.', }); } return result; } /** * Wasserstandsverfahren fuer den Rest: Zeit, die die gewichtete Verteilung * nicht unterbringen konnte, geht an die Phasen, die noch Spielraum bis zu * ihrer Hoechstfreigabezeit haben - auch an solche, die zuvor auf ihre * Mindestfreigabezeit gesetzt worden sind. Gibt zurueck, welche Phasen * aufgestockt wurden und was danach noch uebrig ist. * * Bis dahin ging der Rest an die Phase mit dem groessten Gewicht - also gerade * an die Phase, die soeben an ihrer Obergrenze gekappt worden war. Eine von * Hand vorgegebene Freigabezeit (minGreen = maxGreen) wurde damit * ueberschrieben, waehrend die Phase mit freiem Spielraum leer ausging: 40 s * eingetragen, 55 s geplant, und die Fussgaengerphase blieb bei ihren 20 s. * Ohne Handvorgabe wurde auf demselben Weg die Hoechstfreigabezeit * ueberschritten - 260 s bei einer Obergrenze von 120 s, ohne dass eine * Pruefung das beanstandet haette. */ function restAufPhasenMitSpielraum( result: number[], phases: readonly GreenSplitPhase[], weights: readonly number[], rest: Seconds, ): { readonly aufgestockt: readonly number[]; readonly rest: Seconds } { const aufgestockt = new Set(); let leftover = rest; // Je Durchlauf wird entweder der Rest vollstaendig vergeben oder mindestens // eine Phase an ihre Obergrenze gesetzt; mehr Durchlaeufe als Phasen sind // deshalb nicht noetig. for (let iteration = 0; iteration <= phases.length && leftover > EPSILON; iteration += 1) { const frei: number[] = []; for (let i = 0; i < phases.length; i += 1) { if (safeMax(phases[i]?.maxGreen) - (result[i] ?? 0) > EPSILON) frei.push(i); } if (frei.length === 0) break; const freiesGewicht = frei.reduce((sum, i) => sum + (weights[i] ?? 0), 0); let vergeben = 0; for (const i of frei) { const anteil = freiesGewicht > 0 ? (leftover * (weights[i] ?? 0)) / freiesGewicht : leftover / frei.length; const spielraum = safeMax(phases[i]?.maxGreen) - (result[i] ?? 0); const zuschlag = Math.min(anteil, spielraum); if (zuschlag <= 0) continue; result[i] = (result[i] ?? 0) + zuschlag; vergeben += zuschlag; aufgestockt.add(i); } if (vergeben <= EPSILON) break; leftover -= vergeben; } return { aufgestockt: [...aufgestockt].sort((a, b) => a - b), rest: leftover }; } /** * Rundet auf ganze Sekunden, ohne die Summe zu veraendern (groesster Rest). * Mindestfreigabezeiten bleiben dabei gewahrt. * * Die Untergrenze steht VOR dem Summenausgleich fest, und zwar auf die naechste * ganze Sekunde aufgerundet: Wer 12,5 s braucht, bekommt 13 s, und die dafuer * noetige Sekunde wird einer anderen Phase abgezogen. Bis dahin lief die * Absicherung der Untergrenze als letzte Schleife NACH dem Ausgleich und zog * die zusaetzliche Zeit nirgends wieder ab - genau der Fehler des Altbestands, * den der Kopfkommentar von distributeGreenTimes als behoben ausweist. Bei * ganzzahligen Mindestfreigabezeiten - dem Regelfall - war die Schleife inert; * bei einer eingetragenen Mindestfreigabezeit von 12,5 s ergab der Plan 60,5 s * statt der festgelegten 60 s, und 12,5 s sind ohnehin nicht schaltbar. * * Dass die Untergrenzen in die verfuegbare Zeit passen, hat der Aufrufer * geprueft (Code 'umlaufzeit-zu-kurz'); die Schleife fuer negative Reste kann * den Ausgleich deshalb immer erreichen. */ function roundPreservingSum( values: readonly number[], target: Seconds, phases: readonly GreenSplitPhase[], notes: CalculationNote[], ): number[] { const untergrenzen = values.map((_v, i) => ganzeSekundenUntergrenze(phases[i]?.minGreen)); const floors = values.map((v, i) => Math.max(Math.floor(v), untergrenzen[i] ?? 0)); const sumFloors = floors.reduce((a, b) => a + b, 0); let deficit = Math.round(target) - sumFloors; /* * Der Rest wird gegen den TATSAECHLICH gesetzten Ausgangswert gebildet, nicht * gegen Math.floor des Rohwerts. Eine Phase, deren Untergrenze soeben * aufgerundet wurde, hat ihre Sekunde schon bekommen; stuende sie mit dem * Rest des Rohwerts in der Reihe, bekaeme sie eine zweite: 12,5 s eingetragen, * 14 s geschaltet - anderthalb Sekunden ueber der Vorgabe -, waehrend einer * Phase mit Verkehrsstaerke ohne Grund eine Sekunde fehlt. Ihr Rest ist * negativ, sie steht damit hinten und kommt erst zum Zuge, wenn sonst niemand * mehr in Frage kommt. */ const order = values .map((v, i) => ({ i, frac: v - (floors[i] ?? Math.floor(v)) })) .sort((a, b) => b.frac - a.frac); const result = [...floors]; // Ueberschuss verteilen: je 1 s an die Phasen mit dem groessten Rest. const bekommen = new Set(); for (const { i } of order) { if (deficit <= 0) break; result[i] = (result[i] ?? 0) + 1; bekommen.add(i); deficit -= 1; } /* * DIE ZUSAETZLICHE SEKUNDE BEI GLEICHSTAND WIRD GESAGT (Fassung 5.24.0). * * Ein Signalzeitenplan schaltet in ganzen Sekunden. Laesst sich die * verfuegbare Zeit nicht ganzzahlig aufteilen, MUSS eine Phase eine Sekunde * mehr bekommen als eine gleichgestellte - daran ist nichts zu aendern, und * jede Regel dafuer waere ebenso beliebig wie die jetzige (stabile * Sortierung, also der kleinere Index). Zu aendern war, dass es niemand * erfuhr: An der einstreifigen Verkehrsfuehrung mit zwei in allem gleichen * Richtungen und 99 s Umlaufzeit standen 43 s und 42 s im Plan, ohne ein * Wort dazu - und bei erfassten, gleich grossen Verkehrsstaerken trug das * Ergebnis ueberhaupt keine Meldung. Wo es keinen Grund gibt, gehoert das * hingeschrieben, nicht verdeckt. * * Gemeldet wird nur der echte Gleichstand: eine Phase, die die Sekunde * bekommt, neben einer mit demselben Rest, die sie nicht bekommt. Bei * verschiedenen Resten entscheidet der Rest, und der ist eine Auskunft. */ if (bekommen.size > 0) { const rest = (i: number): number => order.find((e) => e.i === i)?.frac ?? 0; const uebergangen = order.filter((e) => !bekommen.has(e.i)); const gleichauf = [...bekommen] .filter((i) => uebergangen.some((e) => Math.abs(e.frac - rest(i)) <= EPSILON)) .sort((a, b) => a - b); if (gleichauf.length > 0) { const namen = gleichauf.map((i) => `Phase ${i + 1}`).join(', '); notes.push({ severity: 'hinweis', code: 'freigabezeit-restsekunde', message: `Die verfügbare Freigabezeit von ${format(target)} s lässt sich nicht ganzzahlig auf ` + `${values.length} Phasen aufteilen. Je eine zusätzliche Sekunde geht an ${namen}; ` + 'gleichgestellte Phasen gehen leer aus. Ein sachlicher Grund für diese Auswahl besteht ' + 'nicht – sie folgt der Reihenfolge der Phasen. Wählen Sie eine Umlaufzeit, die ' + 'aufgeht, wenn beide Richtungen dieselbe Freigabezeit tragen sollen.', }); } } // Bei negativem Rest (Aufrunden einer Untergrenze oder Abrundung hat zu viel // vergeben) einzelne Sekunden wieder abziehen - aber nie unter die // Untergrenze im Sekundenraster. let index = order.length - 1; while (deficit < 0 && index >= 0) { const i = order[index]?.i; if (i !== undefined) { const min = untergrenzen[i] ?? 0; if ((result[i] ?? 0) > min) { result[i] = (result[i] ?? 0) - 1; deficit += 1; continue; } } index -= 1; } return result; } function safeMin(value: Seconds | undefined): Seconds { return isFiniteNumber(value) && value > 0 ? value : 0; } /** * Untergrenze im Sekundenraster. Ein Signalzeitenplan schaltet in ganzen * Sekunden; eine Mindestfreigabezeit von 12,5 s ist nur einzuhalten, indem * 13 s geschaltet werden. Aufgerundet wird, weil das die sichere Richtung ist. */ function ganzeSekundenUntergrenze(value: Seconds | undefined): Seconds { return Math.ceil(safeMin(value)); } /** * Obergrenze der Freigabezeit - oder unendlich, wenn keine gesetzt ist. * * UNBEGRENZT HEISST `undefined` (Fassung 5.10.0), so wie die Schnittstelle es * sagt ("Optionale Obergrenze"). Hier stand `value > 0`, und damit galt eine * Phase mit der Obergrenze 0 s als Phase OHNE Obergrenze - das Gegenteil * dessen, was eingetragen wurde. Erreichbar ueber das Feld "Feste * Freigabezeit", das 0 s ausdruecklich zulaesst und von "leer" unterscheidet: * An einer Phase ohne Signalgruppe ist die Mindestfreigabezeit 0, die Vorgabe * wird also nicht als zu kurz abgelehnt, und der Planaufbau setzt minGreen = * maxGreen = 0. Die Phase zog danach die ganze Zeit an sich, die die uebrigen * wegen ihrer Hoechstfreigabezeit nicht aufnehmen konnten - waehrend der * Hinweis 'freigabezeit-rest-umgelegt' zusagt, eine von Hand vorgegebene * Freigabezeit bleibe unangetastet. * * `safeMin` daneben behandelt 0 seit jeher so. Ein negativer Wert bleibt * "unbegrenzt": Er kommt hier nicht an, weil der Planaufbau ihn ueber * 'phase-freigabezeit-zu-kurz' abfaengt. */ function safeMax(value: Seconds | undefined): Seconds { return isFiniteNumber(value) && value >= 0 ? value : Number.POSITIVE_INFINITY; } function format(value: number): string { if (!Number.isFinite(value)) return 'unendlich'; return roundTo(value, 1).toString().replace('.', ','); }