lsa-planer

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

/ src domain rilsa greenSplit.ts

24,1 KB Rohdatei
src/domain/rilsa/greenSplit.ts — 565 Zeilen
1 import type { Ratio, Seconds } from '../units';
2 import { isFiniteNumber, roundTo } from '../units';
3 import type { CalculationNote } from './types';
4
5 /** Schranke gegen Rundungsreste der Gleitkommarechnung (Sekunden). */
6 const EPSILON = 1e-9;
7
8 /** Eine Phase im Sinne der Freigabezeitverteilung. */
9 export interface GreenSplitPhase {
10 readonly id: string;
11 /** Massgebender Saettigungsgrad y der Phase; bestimmt den Verteilungsanteil. */
12 readonly weight: Ratio;
13 /** Mindestfreigabezeit der massgebenden Signalgruppe dieser Phase. */
14 readonly minGreen: Seconds;
15 /** Optionale Obergrenze der Freigabezeit. */
16 readonly maxGreen?: Seconds;
17 }
18
19 export interface GreenSplitResult {
20 readonly phases: readonly { readonly id: string; readonly green: Seconds }[];
21 /** Fuer Freigabezeiten verfuegbare Zeit: tU abzueglich aller Uebergangszeiten. */
22 readonly availableGreen: Seconds;
23 /** Summe aller Phasenuebergangszeiten. */
24 readonly transitionTime: Seconds;
25 /** Ist die Verteilung unter den gegebenen Randbedingungen ueberhaupt moeglich? */
26 readonly feasible: boolean;
27 readonly notes: readonly CalculationNote[];
28 }
29
30 /**
31 * Verteilt die Freigabezeiten auf die Phasen.
32 *
33 * verfuegbar = tU - Summe der Phasenuebergangszeiten
34 * g_i = verfuegbar * y_i / Y, begrenzt auf [minGreen, maxGreen]
35 *
36 * Die Summe der ausgegebenen Freigabezeiten zuzueglich der Uebergangszeiten
37 * ergibt exakt die Umlaufzeit. Genau das leistete der Altbestand nicht: dort
38 * wurden die Anteile einzeln gerundet und anschliessend jeweils auf die
39 * Mindestfreigabezeit angehoben, ohne den entstandenen Ueberhang an anderer
40 * Stelle abzuziehen. Die Summe der Phasendauern konnte dadurch die Umlaufzeit
41 * ueberschreiten - der ausgegebene Signalzeitenplan war nicht schaltbar.
42 * Ausserdem wurden dort die Zwischenzeiten bei der verfuegbaren Zeit gar nicht
43 * abgezogen, sondern durch eine Pauschale von 4 s je Phase ersetzt.
44 *
45 * Massgebend ist dabei das Sekundenraster: Ein Signalzeitenplan schaltet in
46 * ganzen Sekunden. Eine gebrochene Mindestfreigabezeit (12,5 s aus dem Feld
47 * "Mindestfreigabezeit" oder aus einer eingelesenen Projektdatei) wird deshalb
48 * schon vor der Verteilung auf die naechste ganze Sekunde AUFgerundet - das ist
49 * die sichere Richtung - und die dafuer noetige Zeit bei einer anderen Phase
50 * abgezogen. Reicht die Umlaufzeit dafuer nicht, wird das gemeldet
51 * ('umlaufzeit-zu-kurz') und nicht die Umlaufzeit stillschweigend verlaengert.
52 *
53 * Ist die Untergrenze zugleich Obergrenze - eine von Hand vorgegebene
54 * Freigabezeit von 5,5 s setzt minGreen = maxGreen -, geht das Sekundenraster
55 * der Obergrenze vor: geschaltet werden 6 s. Die Mindestfreigabezeit zu
56 * unterschreiten waere die gefaehrliche Richtung, eine halbe Sekunde ueber
57 * einer gebrochenen Obergrenze ist es nicht.
58 */
59 export function distributeGreenTimes(
60 cycleTime: Seconds,
61 phases: readonly GreenSplitPhase[],
62 transitionTimes: readonly Seconds[],
63 ): GreenSplitResult {
64 const notes: CalculationNote[] = [];
65
66 const transitionTime = transitionTimes.reduce(
67 (sum, t) => sum + (isFiniteNumber(t) && t > 0 ? t : 0),
68 0,
69 );
70
71 if (phases.length === 0) {
72 notes.push({
73 severity: 'fehler',
74 code: 'keine-phasen',
75 message: 'Es ist keine Phase definiert; eine Freigabezeitverteilung ist nicht möglich.',
76 });
77 return { phases: [], availableGreen: 0, transitionTime, feasible: false, notes };
78 }
79
80 if (!isFiniteNumber(cycleTime) || cycleTime <= 0) {
81 notes.push({
82 severity: 'fehler',
83 code: 'umlaufzeit-ungueltig',
84 message: 'Die Umlaufzeit ist kein gültiger Wert größer als 0.',
85 });
86 return {
87 phases: phases.map((p) => ({ id: p.id, green: 0 })),
88 availableGreen: 0,
89 transitionTime,
90 feasible: false,
91 notes,
92 };
93 }
94
95 const availableGreen = cycleTime - transitionTime;
96 // Gerechnet wird gegen die Untergrenzen IM SEKUNDENRASTER: 12,5 s lassen
97 // sich nur einhalten, indem 13 s geschaltet werden. Bei ganzzahligen
98 // Mindestfreigabezeiten - dem Regelfall - aendert das nichts.
99 const minSum = phases.reduce((sum, p) => sum + ganzeSekundenUntergrenze(p.minGreen), 0);
100
101 /*
102 * Aufgerundet wird, aber nicht stumm. Wer 5,5 s als feste Freigabezeit in
103 * seiner Projektdatei stehen hat, bekommt 6 s geschaltet und muss das im
104 * Rechenweg wiederfinden - sonst steht im Feld eine Zahl und im Plan eine
105 * andere. Fuer die von Hand vorgegebene Zwischenzeit tut das Programm seit
106 * jeher dasselbe (`zwischenzeit-vorgabe-aufgerundet` in signalPlan.ts).
107 *
108 * Ueber die Oberflaeche ist der Fall nicht erreichbar - das Feld "Feste
109 * Freigabezeit" hat Schrittweite 1 -, ueber eine eingelesene Projektdatei
110 * schon. Beim Regelfall der ganzzahligen Vorgabe schweigt die Meldung.
111 */
112 phases.forEach((p, index) => {
113 const vorgabe = safeMin(p.minGreen);
114 const angesetzt = ganzeSekundenUntergrenze(p.minGreen);
115 if (angesetzt === vorgabe) return;
116 const fest = isFiniteNumber(p.maxGreen) && p.maxGreen === p.minGreen;
117 notes.push({
118 severity: 'hinweis',
119 code: 'freigabezeit-vorgabe-aufgerundet',
120 message:
121 `Phase ${index + 1}: Die vorgegebene ${fest ? 'feste Freigabezeit' : 'Mindestfreigabezeit'} ` +
122 `${format(vorgabe)} s wird auf ganze Sekunden aufgerundet; angesetzt werden ` +
123 `${angesetzt} s. Ein Signalzeitenplan schaltet in ganzen Sekunden.`,
124 });
125 });
126
127 if (availableGreen < minSum) {
128 notes.push({
129 severity: 'fehler',
130 code: 'umlaufzeit-zu-kurz',
131 message:
132 `Bei einer Umlaufzeit von ${format(cycleTime)} s verbleiben nach Abzug der Übergangszeiten ` +
133 `von ${format(transitionTime)} s nur ${format(availableGreen)} s für die Freigabezeiten. ` +
134 `Erforderlich sind mindestens ${format(minSum)} s. Die Umlaufzeit ist auf mindestens ` +
135 `${format(minSum + transitionTime)} s zu erhöhen oder die Phasenanzahl zu verringern.`,
136 });
137 return {
138 phases: phases.map((p) => ({ id: p.id, green: ganzeSekundenUntergrenze(p.minGreen) })),
139 availableGreen,
140 transitionTime,
141 feasible: false,
142 notes,
143 };
144 }
145
146 const raw = proportionalSplit(availableGreen, phases, notes);
147 const rounded = roundPreservingSum(raw, availableGreen, phases, notes);
148
149 return {
150 phases: phases.map((p, index) => ({
151 id: p.id,
152 green: rounded[index] ?? ganzeSekundenUntergrenze(p.minGreen),
153 })),
154 availableGreen,
155 transitionTime,
156 feasible: true,
157 notes,
158 };
159 }
160
161 /**
162 * Proportionale Verteilung mit Beruecksichtigung der Schranken.
163 * Phasen, die an eine Schranke stossen, werden festgehalten; ihr Ueberschuss
164 * bzw. Fehlbetrag wird unter den uebrigen Phasen neu verteilt, bis sich nichts
165 * mehr aendert.
166 */
167 function proportionalSplit(
168 available: Seconds,
169 phases: readonly GreenSplitPhase[],
170 notes: CalculationNote[],
171 ): number[] {
172 const n = phases.length;
173 const weights = phases.map((p) => (isFiniteNumber(p.weight) && p.weight > 0 ? p.weight : 0));
174 const weightSum = weights.reduce((a, b) => a + b, 0);
175
176 if (weightSum === 0) {
177 notes.push({
178 severity: 'warnung',
179 code: 'keine-gewichtung',
180 message:
181 'Es liegen keine Verkehrsstärken vor. Die Freigabezeiten werden gleichmäßig verteilt; ' +
182 'für eine bedarfsgerechte Aufteilung sind Verkehrsstärken je Signalgruppe zu erfassen.',
183 });
184 weights.fill(1);
185 }
186
187 const result = new Array<number>(n).fill(0);
188 const fixed = new Array<boolean>(n).fill(false);
189 let remaining = available;
190
191 // Hoechstens n Durchlaeufe: je Durchlauf wird mindestens eine Phase fixiert.
192 for (let iteration = 0; iteration <= n; iteration += 1) {
193 const activeWeight = phases.reduce(
194 (sum, _p, i) => (fixed[i] === true ? sum : sum + (weights[i] ?? 0)),
195 0,
196 );
197 let changed = false;
198
199 for (let i = 0; i < n; i += 1) {
200 if (fixed[i] === true) continue;
201 const phase = phases[i]!;
202 const share =
203 activeWeight > 0 ? (remaining * (weights[i] ?? 0)) / activeWeight : remaining / n;
204 const min = safeMin(phase.minGreen);
205 const max = safeMax(phase.maxGreen);
206
207 if (share < min) {
208 result[i] = min;
209 fixed[i] = true;
210 remaining -= min;
211 changed = true;
212 } else if (share > max) {
213 result[i] = max;
214 fixed[i] = true;
215 remaining -= max;
216 changed = true;
217 } else {
218 result[i] = share;
219 }
220 }
221
222 if (!changed) break;
223 }
224
225 /*
226 * Reststuecke entstehen, sobald die gewichtete Verteilung die verfuegbare
227 * Zeit nicht unterbringt: weil eine Phase an ihrer Obergrenze gekappt wurde,
228 * oder weil eine Phase ohne Gewicht - eine reine Fussgaengerphase traegt
229 * keine Saettigungsverkehrsstaerke - stets den Anteil 0 erhaelt und deshalb
230 * an ihrer Mindestfreigabezeit festgehalten wird.
231 */
232 const assigned = result.reduce((a, b) => a + b, 0);
233 const rest = available - assigned;
234 let leftover = rest;
235 let aufgestockt: readonly number[] = [];
236 if (leftover > 0.5) {
237 const verteilt = restAufPhasenMitSpielraum(result, phases, weights, leftover);
238 aufgestockt = verteilt.aufgestockt;
239 leftover = verteilt.rest;
240 }
241 if (leftover > 0.5) {
242 /*
243 * Jetzt liegt jede Phase an ihrer Hoechstfreigabezeit, und die feste
244 * Umlaufzeit laesst sich damit nicht ausfuellen. Der Rest muss trotzdem
245 * vergeben werden, damit die Summe der Phasendauern die Umlaufzeit trifft -
246 * verteilt wird er ueber die Phasen mit Spielraum, nach Gewichten und ohne
247 * Gewichte zu gleichen Teilen. Welche das sind, sagt der uebernaechste
248 * Absatz.
249 *
250 * BIS 5.22.0 GING ER GANZ AN EINE PHASE, naemlich an die mit dem groessten
251 * Gewicht. Ohne Verkehrsstaerken sind alle Gewichte gleich, und dann
252 * entschied die Reihenfolge: Die erste Phase bekam alles. An einer
253 * einstreifigen Verkehrsfuehrung - zwei Richtungen, derselbe Raeumweg,
254 * dieselbe Zwischenzeit - kam damit ein Plan heraus, dessen beide
255 * Richtungen ohne jeden sachlichen Grund verschieden lang Gruen hatten:
256 * 100 s Umlaufzeit, 8 s Zwischenzeit je Richtung, 40 s Hoechstfreigabezeit
257 * ergaben 44/40 s statt 42/42 s. Aufgefallen ist das beim Nachrechnen eines
258 * angeordneten Plans. Ein Gleichstand der Gewichte ist keine Auskunft
259 * darueber, welche Phase die Zeit bekommen soll; wo nichts die Phasen
260 * unterscheidet, duerfen sie sich auch im Plan nicht unterscheiden.
261 *
262 * PHASEN OHNE SPIELRAUM BLEIBEN AUSSEN VOR, solange es andere gibt.
263 * `minGreen === maxGreen` heisst: Diese Phase hat gar keinen Bereich, ihre
264 * Dauer liegt fest. Das kommt auf zwei Wegen zustande - eine von Hand
265 * eingetragene feste Freigabezeit (dann setzt der Aufrufer beide Schranken
266 * auf die Vorgabe) oder eine Hoechstfreigabezeit, die unter der
267 * Mindestfreigabezeit lag und auf sie angehoben wurde. In beiden Faellen
268 * ist die Zahl das Ergebnis einer ausdruecklichen Angabe und nicht der
269 * obere Rand eines Bereichs; der Rest gehoert an die Phasen, die einen
270 * Bereich haben.
271 *
272 * DAS WAR NICHT DER ERSTE ANLAUF. Zuerst ging der Rest gleichmaessig an
273 * wirklich alle Phasen, und das hat eine Wache umgeworfen: Aus 6 s fest
274 * eingetragen wurden 7 s geschaltet
275 * (tests/domain/freigabezeitVorgabeAufgerundet.test.ts). Erst wenn jede
276 * Phase festliegt, bleibt nichts anderes uebrig, als sie alle zu
277 * ueberschreiten - dann sagt die Meldung auch das.
278 */
279 const gebunden = phases.map((p) => isFiniteNumber(p.maxGreen) && p.maxGreen === p.minGreen);
280 const alleGebunden = gebunden.every((f) => f);
281 const empfaenger: number[] = [];
282 for (let i = 0; i < n; i += 1) {
283 if (alleGebunden || gebunden[i] !== true) empfaenger.push(i);
284 }
285 const gewichtSumme = empfaenger.reduce((sum, i) => sum + (weights[i] ?? 0), 0);
286 const zuVerteilen = leftover;
287 for (const i of empfaenger) {
288 const anteil =
289 gewichtSumme > 0
290 ? (zuVerteilen * (weights[i] ?? 0)) / gewichtSumme
291 : zuVerteilen / empfaenger.length;
292 result[i] = (result[i] ?? 0) + anteil;
293 }
294
295 /*
296 * Die Meldung sagte das Gegenteil dessen, was geschieht.
297 *
298 * "Konnten keiner Phase zugeordnet werden" - zugeordnet werden sie sehr
299 * wohl, unmittelbar darueber. Sie nannte weder die betroffene Phase noch
300 * den ueberschriebenen Wert, und sie stand auf "hinweis". Trifft es eine
301 * Phase mit vorgegebener Freigabezeit, wird diese Vorgabe damit
302 * stillschweigend ueberschrieben: 10 s eingetragen, 28 s geplant.
303 *
304 * Der erste Satz stimmte ausserdem nicht: Er stand auch dann da, wenn eine
305 * Phase an ihrer MINDESTfreigabezeit hing und bis zur Hoechstfreigabezeit
306 * noch hundert Sekunden frei hatte. Der Planer las daraus, seine
307 * Umlaufzeit sei zu lang, obwohl nur die Verteilung versagt hatte. Diese
308 * Phasen sind jetzt vorher bedient; wer hier ankommt, liegt tatsaechlich
309 * ueberall an der Obergrenze.
310 *
311 * SEIT 5.23.0 nennt sie keine einzelne Phase mehr, sondern die, die den
312 * Rest wirklich bekommen, und dazu die Zahl, die weiterhilft: um wieviel
313 * die Umlaufzeit zu kuerzen ist, damit keine Hoechstfreigabezeit
314 * ueberschritten wird. Das ist genau der Rest.
315 */
316 const namen = empfaenger.map((i) => `Phase ${i + 1}`).join(', ');
317 notes.push({
318 severity: 'warnung',
319 code: 'freigabezeit-rest',
320 message:
321 `Alle Phasen erreichen ihre Höchstfreigabezeit; ${format(zuVerteilen)} s der Umlaufzeit ` +
322 `bleiben übrig. Sie gehen an ${namen} – nach Verkehrsstärken, und wo keine vorliegen, zu ` +
323 'gleichen Teilen. ' +
324 (alleGebunden
325 ? 'Keine Phase hat dabei Spielraum; auch eine fest vorgegebene Freigabezeit wird ' +
326 'überschritten.'
327 : 'Phasen, deren Freigabezeit festliegt, bleiben unangetastet.') +
328 ` Verkürzen Sie die Umlaufzeit um ${format(zuVerteilen)} s, wenn das nicht gewollt ist.`,
329 });
330 } else if (aufgestockt.length > 0) {
331 /*
332 * Nur, wenn kein Rest geblieben ist: Sonst stuenden zwei Meldungen zur
333 * selben Sache im Bericht, und die Warnung oben sagt bereits, dass jede
334 * Phase an ihrer Obergrenze liegt.
335 *
336 * EIGENE KENNUNG (Fassung 5.9.0): Bis dahin trug dieser Hinweis die
337 * Kennung 'freigabezeit-rest' und erbte damit im Pruefbericht deren
338 * Ueberschrift "Freigabezeit konnte nicht vollständig verteilt werden" und
339 * deren Rat, Freigabezeiten und Umlaufzeit zu pruefen. Hier ist die Zeit
340 * vollstaendig vergeben, und im Regelfall - eine Fussgaengerphase ohne
341 * Verkehrsstaerke - ist nichts zu pruefen. Zwei Sachverhalte unter einer
342 * Kennung ergeben eine Zeile, die sich selbst widerspricht.
343 */
344 const namen = aufgestockt.map((i) => `Phase ${i + 1}`).join(', ');
345 notes.push({
346 severity: 'hinweis',
347 code: 'freigabezeit-rest-umgelegt',
348 message:
349 `${format(rest)} s der Umlaufzeit ließen sich nicht nach Verkehrsstärken verteilen: Die ` +
350 'dafür maßgebenden Phasen liegen an ihrer Höchstfreigabezeit oder führen keine ' +
351 `Verkehrsstärke. Die Zeit geht an ${namen}, wo bis zur Höchstfreigabezeit noch Spielraum ` +
352 'ist; eine von Hand vorgegebene Freigabezeit bleibt dabei unangetastet.',
353 });
354 }
355
356 return result;
357 }
358
359 /**
360 * Wasserstandsverfahren fuer den Rest: Zeit, die die gewichtete Verteilung
361 * nicht unterbringen konnte, geht an die Phasen, die noch Spielraum bis zu
362 * ihrer Hoechstfreigabezeit haben - auch an solche, die zuvor auf ihre
363 * Mindestfreigabezeit gesetzt worden sind. Gibt zurueck, welche Phasen
364 * aufgestockt wurden und was danach noch uebrig ist.
365 *
366 * Bis dahin ging der Rest an die Phase mit dem groessten Gewicht - also gerade
367 * an die Phase, die soeben an ihrer Obergrenze gekappt worden war. Eine von
368 * Hand vorgegebene Freigabezeit (minGreen = maxGreen) wurde damit
369 * ueberschrieben, waehrend die Phase mit freiem Spielraum leer ausging: 40 s
370 * eingetragen, 55 s geplant, und die Fussgaengerphase blieb bei ihren 20 s.
371 * Ohne Handvorgabe wurde auf demselben Weg die Hoechstfreigabezeit
372 * ueberschritten - 260 s bei einer Obergrenze von 120 s, ohne dass eine
373 * Pruefung das beanstandet haette.
374 */
375 function restAufPhasenMitSpielraum(
376 result: number[],
377 phases: readonly GreenSplitPhase[],
378 weights: readonly number[],
379 rest: Seconds,
380 ): { readonly aufgestockt: readonly number[]; readonly rest: Seconds } {
381 const aufgestockt = new Set<number>();
382 let leftover = rest;
383
384 // Je Durchlauf wird entweder der Rest vollstaendig vergeben oder mindestens
385 // eine Phase an ihre Obergrenze gesetzt; mehr Durchlaeufe als Phasen sind
386 // deshalb nicht noetig.
387 for (let iteration = 0; iteration <= phases.length && leftover > EPSILON; iteration += 1) {
388 const frei: number[] = [];
389 for (let i = 0; i < phases.length; i += 1) {
390 if (safeMax(phases[i]?.maxGreen) - (result[i] ?? 0) > EPSILON) frei.push(i);
391 }
392 if (frei.length === 0) break;
393
394 const freiesGewicht = frei.reduce((sum, i) => sum + (weights[i] ?? 0), 0);
395 let vergeben = 0;
396 for (const i of frei) {
397 const anteil =
398 freiesGewicht > 0 ? (leftover * (weights[i] ?? 0)) / freiesGewicht : leftover / frei.length;
399 const spielraum = safeMax(phases[i]?.maxGreen) - (result[i] ?? 0);
400 const zuschlag = Math.min(anteil, spielraum);
401 if (zuschlag <= 0) continue;
402 result[i] = (result[i] ?? 0) + zuschlag;
403 vergeben += zuschlag;
404 aufgestockt.add(i);
405 }
406 if (vergeben <= EPSILON) break;
407 leftover -= vergeben;
408 }
409
410 return { aufgestockt: [...aufgestockt].sort((a, b) => a - b), rest: leftover };
411 }
412
413 /**
414 * Rundet auf ganze Sekunden, ohne die Summe zu veraendern (groesster Rest).
415 * Mindestfreigabezeiten bleiben dabei gewahrt.
416 *
417 * Die Untergrenze steht VOR dem Summenausgleich fest, und zwar auf die naechste
418 * ganze Sekunde aufgerundet: Wer 12,5 s braucht, bekommt 13 s, und die dafuer
419 * noetige Sekunde wird einer anderen Phase abgezogen. Bis dahin lief die
420 * Absicherung der Untergrenze als letzte Schleife NACH dem Ausgleich und zog
421 * die zusaetzliche Zeit nirgends wieder ab - genau der Fehler des Altbestands,
422 * den der Kopfkommentar von distributeGreenTimes als behoben ausweist. Bei
423 * ganzzahligen Mindestfreigabezeiten - dem Regelfall - war die Schleife inert;
424 * bei einer eingetragenen Mindestfreigabezeit von 12,5 s ergab der Plan 60,5 s
425 * statt der festgelegten 60 s, und 12,5 s sind ohnehin nicht schaltbar.
426 *
427 * Dass die Untergrenzen in die verfuegbare Zeit passen, hat der Aufrufer
428 * geprueft (Code 'umlaufzeit-zu-kurz'); die Schleife fuer negative Reste kann
429 * den Ausgleich deshalb immer erreichen.
430 */
431 function roundPreservingSum(
432 values: readonly number[],
433 target: Seconds,
434 phases: readonly GreenSplitPhase[],
435 notes: CalculationNote[],
436 ): number[] {
437 const untergrenzen = values.map((_v, i) => ganzeSekundenUntergrenze(phases[i]?.minGreen));
438 const floors = values.map((v, i) => Math.max(Math.floor(v), untergrenzen[i] ?? 0));
439 const sumFloors = floors.reduce((a, b) => a + b, 0);
440 let deficit = Math.round(target) - sumFloors;
441
442 /*
443 * Der Rest wird gegen den TATSAECHLICH gesetzten Ausgangswert gebildet, nicht
444 * gegen Math.floor des Rohwerts. Eine Phase, deren Untergrenze soeben
445 * aufgerundet wurde, hat ihre Sekunde schon bekommen; stuende sie mit dem
446 * Rest des Rohwerts in der Reihe, bekaeme sie eine zweite: 12,5 s eingetragen,
447 * 14 s geschaltet - anderthalb Sekunden ueber der Vorgabe -, waehrend einer
448 * Phase mit Verkehrsstaerke ohne Grund eine Sekunde fehlt. Ihr Rest ist
449 * negativ, sie steht damit hinten und kommt erst zum Zuge, wenn sonst niemand
450 * mehr in Frage kommt.
451 */
452 const order = values
453 .map((v, i) => ({ i, frac: v - (floors[i] ?? Math.floor(v)) }))
454 .sort((a, b) => b.frac - a.frac);
455
456 const result = [...floors];
457
458 // Ueberschuss verteilen: je 1 s an die Phasen mit dem groessten Rest.
459 const bekommen = new Set<number>();
460 for (const { i } of order) {
461 if (deficit <= 0) break;
462 result[i] = (result[i] ?? 0) + 1;
463 bekommen.add(i);
464 deficit -= 1;
465 }
466
467 /*
468 * DIE ZUSAETZLICHE SEKUNDE BEI GLEICHSTAND WIRD GESAGT (Fassung 5.24.0).
469 *
470 * Ein Signalzeitenplan schaltet in ganzen Sekunden. Laesst sich die
471 * verfuegbare Zeit nicht ganzzahlig aufteilen, MUSS eine Phase eine Sekunde
472 * mehr bekommen als eine gleichgestellte - daran ist nichts zu aendern, und
473 * jede Regel dafuer waere ebenso beliebig wie die jetzige (stabile
474 * Sortierung, also der kleinere Index). Zu aendern war, dass es niemand
475 * erfuhr: An der einstreifigen Verkehrsfuehrung mit zwei in allem gleichen
476 * Richtungen und 99 s Umlaufzeit standen 43 s und 42 s im Plan, ohne ein
477 * Wort dazu - und bei erfassten, gleich grossen Verkehrsstaerken trug das
478 * Ergebnis ueberhaupt keine Meldung. Wo es keinen Grund gibt, gehoert das
479 * hingeschrieben, nicht verdeckt.
480 *
481 * Gemeldet wird nur der echte Gleichstand: eine Phase, die die Sekunde
482 * bekommt, neben einer mit demselben Rest, die sie nicht bekommt. Bei
483 * verschiedenen Resten entscheidet der Rest, und der ist eine Auskunft.
484 */
485 if (bekommen.size > 0) {
486 const rest = (i: number): number => order.find((e) => e.i === i)?.frac ?? 0;
487 const uebergangen = order.filter((e) => !bekommen.has(e.i));
488 const gleichauf = [...bekommen]
489 .filter((i) => uebergangen.some((e) => Math.abs(e.frac - rest(i)) <= EPSILON))
490 .sort((a, b) => a - b);
491 if (gleichauf.length > 0) {
492 const namen = gleichauf.map((i) => `Phase ${i + 1}`).join(', ');
493 notes.push({
494 severity: 'hinweis',
495 code: 'freigabezeit-restsekunde',
496 message:
497 `Die verfügbare Freigabezeit von ${format(target)} s lässt sich nicht ganzzahlig auf ` +
498 `${values.length} Phasen aufteilen. Je eine zusätzliche Sekunde geht an ${namen}; ` +
499 'gleichgestellte Phasen gehen leer aus. Ein sachlicher Grund für diese Auswahl besteht ' +
500 'nicht – sie folgt der Reihenfolge der Phasen. Wählen Sie eine Umlaufzeit, die ' +
501 'aufgeht, wenn beide Richtungen dieselbe Freigabezeit tragen sollen.',
502 });
503 }
504 }
505
506 // Bei negativem Rest (Aufrunden einer Untergrenze oder Abrundung hat zu viel
507 // vergeben) einzelne Sekunden wieder abziehen - aber nie unter die
508 // Untergrenze im Sekundenraster.
509 let index = order.length - 1;
510 while (deficit < 0 && index >= 0) {
511 const i = order[index]?.i;
512 if (i !== undefined) {
513 const min = untergrenzen[i] ?? 0;
514 if ((result[i] ?? 0) > min) {
515 result[i] = (result[i] ?? 0) - 1;
516 deficit += 1;
517 continue;
518 }
519 }
520 index -= 1;
521 }
522
523 return result;
524 }
525
526 function safeMin(value: Seconds | undefined): Seconds {
527 return isFiniteNumber(value) && value > 0 ? value : 0;
528 }
529
530 /**
531 * Untergrenze im Sekundenraster. Ein Signalzeitenplan schaltet in ganzen
532 * Sekunden; eine Mindestfreigabezeit von 12,5 s ist nur einzuhalten, indem
533 * 13 s geschaltet werden. Aufgerundet wird, weil das die sichere Richtung ist.
534 */
535 function ganzeSekundenUntergrenze(value: Seconds | undefined): Seconds {
536 return Math.ceil(safeMin(value));
537 }
538
539 /**
540 * Obergrenze der Freigabezeit - oder unendlich, wenn keine gesetzt ist.
541 *
542 * UNBEGRENZT HEISST `undefined` (Fassung 5.10.0), so wie die Schnittstelle es
543 * sagt ("Optionale Obergrenze"). Hier stand `value > 0`, und damit galt eine
544 * Phase mit der Obergrenze 0 s als Phase OHNE Obergrenze - das Gegenteil
545 * dessen, was eingetragen wurde. Erreichbar ueber das Feld "Feste
546 * Freigabezeit", das 0 s ausdruecklich zulaesst und von "leer" unterscheidet:
547 * An einer Phase ohne Signalgruppe ist die Mindestfreigabezeit 0, die Vorgabe
548 * wird also nicht als zu kurz abgelehnt, und der Planaufbau setzt minGreen =
549 * maxGreen = 0. Die Phase zog danach die ganze Zeit an sich, die die uebrigen
550 * wegen ihrer Hoechstfreigabezeit nicht aufnehmen konnten - waehrend der
551 * Hinweis 'freigabezeit-rest-umgelegt' zusagt, eine von Hand vorgegebene
552 * Freigabezeit bleibe unangetastet.
553 *
554 * `safeMin` daneben behandelt 0 seit jeher so. Ein negativer Wert bleibt
555 * "unbegrenzt": Er kommt hier nicht an, weil der Planaufbau ihn ueber
556 * 'phase-freigabezeit-zu-kurz' abfaengt.
557 */
558 function safeMax(value: Seconds | undefined): Seconds {
559 return isFiniteNumber(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
560 }
561
562 function format(value: number): string {
563 if (!Number.isFinite(value)) return 'unendlich';
564 return roundTo(value, 1).toString().replace('.', ',');
565 }