lsa-planer

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

/ src domain plan signalPlan.ts

89,4 KB Rohdatei
src/domain/plan/signalPlan.ts — 2139 Zeilen
1 import type { Meters, Ratio, Seconds } from '../units';
2 import { clamp, isFiniteNumber, roundTo } from '../units';
3 import {
4 ANLAGENART_GRENZEN,
5 aufteilungAusLastzuganteil,
6 clearingSpeedFor,
7 computeCapacity,
8 computeDelay,
9 computeFussRadDelay,
10 sperrzeitenAusFenstern,
11 compareCycleTimeMethods,
12 computeEngstellenIntergreen,
13 computeIntergreen,
14 criticalFlowRatio,
15 distributeGreenTimes,
16 computeCycleTime,
17 furtFreigabebedarf,
18 istLastzuganteil,
19 minGreenTime,
20 resolveRilsaSettings,
21 saturationFlow,
22 schwerverkehrsfaktor,
23 signalGroupTimes,
24 wirksamerSchwerverkehrsanteil,
25 type AnlagenartGrenzen,
26 type CalculationNote,
27 type CapacityResult,
28 type CycleTimeComparison,
29 type Fahrstreifengeometrie,
30 type CycleTimeResult,
31 type FurtFreigabebedarf,
32 type IntergreenResult,
33 type RilsaDefaults,
34 type SignalGroupTimes,
35 } from '../rilsa';
36 import { rueckstaubewertung, type DelayResult, type Rueckstaubewertung } from '../rilsa/delay';
37 import type { ClearingStream, EnteringStream, Movement, UeberfahrzeitAnsatz } from '../rilsa/types';
38 import {
39 demandFor,
40 findSignalGroup,
41 orderedPhases,
42 type Anlagenart,
43 type Conflict,
44 type Phase,
45 type Project,
46 type SignalGroup,
47 type TrafficDemand,
48 } from '../model/project';
49
50 /**
51 * Aufbau des vollstaendigen Signalzeitenplans aus den Projektdaten.
52 *
53 * Diese Datei ist die einzige Stelle, an der aus Phasen, Zwischenzeiten und
54 * Umlaufzeit ein schaltbarer Plan entsteht. Darstellung, Simulation, Pruefung
55 * und Ausdruck arbeiten alle mit demselben Ergebnis.
56 *
57 * Im Altbestand rechnete jede dieser vier Stellen eigenstaendig: die Anzeige
58 * summierte Phasendauern, die Simulation nahm feste 30 s je Phase, der Ausdruck
59 * las die gespeicherte Umlaufzeit und die Pruefung wieder etwas anderes.
60 * Derselbe Knotenpunkt wurde dadurch je nach Ansicht unterschiedlich
61 * dargestellt.
62 */
63
64 /** Ein Freigabezeitfenster einer Signalgruppe innerhalb des Umlaufs. */
65 export interface GreenInterval {
66 /** Beginn der Freigabezeit, bezogen auf den Umlaufbeginn (0 .. tU). */
67 readonly start: Seconds;
68 /** Dauer der Freigabezeit. */
69 readonly duration: Seconds;
70 }
71
72 /**
73 * Meldung aus dem Planaufbau.
74 *
75 * Erweitert die Rechenmeldung um die Signalgruppe, von der sie handelt - damit
76 * der Pruefbericht auf die Gruppe zeigen kann statt pauschal auf das Programm
77 * (dasselbe, was `konfliktId` fuer die Zwischenzeitmeldungen leistet). Alle
78 * Rechenmeldungen bleiben ohne Aenderung gueltige Planmeldungen.
79 */
80 export interface PlanNote extends CalculationNote {
81 /** Signalgruppe, von der die Meldung handelt - nur gesetzt, wo es eine gibt. */
82 readonly signalGroupId?: string;
83 }
84
85 /**
86 * Die Mindestfreigabezeit einer Signalgruppe mit ihrem Rechenweg.
87 *
88 * WARUM NICHT NUR EINE ZAHL: Fuer eine Fussgaengergruppe setzt sich der Wert
89 * aus dem Regelwert (5 s), einer etwaigen Vorgabe und der Furtregel der RiLSA
90 * zusammen. Der Ausdruck und der Bericht muessen sagen koennen, welcher der
91 * drei gewonnen hat und wie er zustande kam - sonst steht dort "6 s" neben
92 * einem Regelwert von 5 s, und niemand weiss, woher die Sekunde stammt.
93 */
94 export interface Mindestfreigabezeit {
95 /**
96 * Massgebende Mindestfreigabezeit in GANZEN Sekunden - der Wert, mit dem
97 * Freigabezeitverteilung und Pruefung rechnen. Der Furtbedarf wird dafuer
98 * aufgerundet, nie abgerundet: Signalzeiten werden in ganzen Sekunden
99 * geschaltet, und 5,83 s Bedarf sind mit 5 s nicht gedeckt.
100 */
101 readonly wert: Seconds;
102 /** Regelwert der Verkehrsart aus den Vorgaben. */
103 readonly regelwert: Seconds;
104 /** Vorgabe an der Gruppe (`minGreenOverride`), sofern eine besteht. */
105 readonly vorgabe: Seconds | null;
106 /**
107 * Furtbedarf nach RiLSA - nur bei Fussgaengergruppen mit erfasster
108 * Furtlaenge (laengster Raeumweg der Konfliktbeziehungen, in denen die
109 * Gruppe raeumt). Ohne solche Beziehung `null`: Dann ist die Furtlaenge
110 * unbekannt, und die Furtregel kann nicht geprueft werden.
111 */
112 readonly furt: FurtFreigabebedarf | null;
113 /** Woher der massgebende Wert stammt. */
114 readonly massgebend: 'regelwert' | 'vorgabe' | 'furt';
115 }
116
117 export interface PlannedSignalGroup {
118 readonly groupId: string;
119 readonly name: string;
120 readonly mode: SignalGroup['mode'];
121 readonly color: string;
122 readonly times: SignalGroupTimes;
123 /** Rechenweg zur Mindestfreigabezeit in `times.minGreen`. */
124 readonly mindestfreigabezeit: Mindestfreigabezeit;
125 /** Freigabezeitfenster; mehrere, wenn die Gruppe in getrennten Phasen freigegeben ist. */
126 readonly greens: readonly GreenInterval[];
127 /** Summe aller Freigabezeiten im Umlauf. */
128 readonly totalGreen: Seconds;
129 readonly capacity: CapacityResult | null;
130 readonly delay: DelayResult | null;
131 /** Massgebender Saettigungsgrad y = q / qS. */
132 readonly flowRatio: Ratio | null;
133 /**
134 * Der mittlere Rueckstau als Laenge, gehalten gegen den erfassten Stauraum.
135 *
136 * `null`, wo die Bemessung keinen Rueckstau liefert - ohne Verkehrsstaerke,
137 * ohne Umlaufzeit oder im HCM-Verfahren, das NGE nicht kennt.
138 */
139 readonly rueckstau: Rueckstaubewertung | null;
140 }
141
142 export interface PlannedPhase {
143 readonly id: string;
144 readonly name: string;
145 readonly start: Seconds;
146 readonly duration: Seconds;
147 readonly signalGroupIds: readonly string[];
148 /** Massgebender Saettigungsgrad der Phase. */
149 readonly flowRatio: Ratio;
150 }
151
152 export interface PhaseTransition {
153 readonly fromPhaseId: string;
154 readonly toPhaseId: string;
155 /** Beginn des Uebergangs, bezogen auf den Umlaufbeginn. */
156 readonly start: Seconds;
157 /** Dauer des Uebergangs: die groesste erforderliche Zwischenzeit. */
158 readonly duration: Seconds;
159 /** Massgebende Beziehung, die die Dauer bestimmt. */
160 readonly critical: {
161 readonly fromGroupId: string;
162 readonly fromGroupName: string;
163 readonly toGroupId: string;
164 readonly toGroupName: string;
165 readonly intergreen: Seconds;
166 } | null;
167 }
168
169 export interface SignalPlan {
170 readonly cycleTime: Seconds;
171 /** Ergebnis der Umlaufzeitermittlung; null bei fester Vorgabe. */
172 readonly cycleResult: CycleTimeResult | null;
173 /**
174 * Alle vier Rechenansaetze nebeneinander, aus denselben Eingaben.
175 *
176 * Immer gefuellt, wenn der Plan Phasen hat - auch bei fester Vorgabe, wo er
177 * die einzige Auskunft darueber ist, ob der Handwert zu den Verkehrsstaerken
178 * passt. `null` nur am leeren Plan.
179 *
180 * DIE MELDUNGEN DER NICHT GEWAEHLTEN VERFAHREN STEHEN NUR HIER und nicht in
181 * `notes`: Sie beziehen sich auf eine Umlaufzeit, die niemand schaltet.
182 */
183 readonly cycleComparison: CycleTimeComparison | null;
184 /** Art der Anlage, aus der die Schranken dieses Plans stammen. */
185 readonly anlagenart: Anlagenart;
186 /**
187 * Die fuer DIESEN Plan geltenden Schranken - Umlaufzeitbereich, groesster
188 * Raeumweg, Wartezeitschwellen.
189 *
190 * Sie stehen getrennt von `defaults`, weil sie etwas anderes sind:
191 * `defaults` sind die Kennwerte, mit denen gerechnet wird (Gelbzeiten,
192 * Geschwindigkeiten, Saettigungsverkehrsstaerke) und die die
193 * Vorgabenverwaltung anzeigt; `grenzen` ist der Rahmen, in dem das Ergebnis
194 * liegen muss. Beides in ein Feld zu legen hiesse, die Vorgabentabelle im
195 * Ausdruck wuerde bei einer Arbeitsstelle "Regelwert der groessten
196 * Umlaufzeit: 300 s" behaupten - eine Zahl, die in der RiLSA nicht steht.
197 */
198 readonly grenzen: AnlagenartGrenzen;
199 readonly phases: readonly PlannedPhase[];
200 readonly transitions: readonly PhaseTransition[];
201 readonly groups: readonly PlannedSignalGroup[];
202 /** Zwischenzeiten je Konfliktbeziehung, Schluessel "fromId|toId". */
203 readonly intergreens: ReadonlyMap<string, ResolvedIntergreen>;
204 /** Summe aller Uebergangszeiten. */
205 readonly transitionTime: Seconds;
206 /** Verlustzeit je Umlauf: Uebergangszeiten zuzueglich Anfahrverluste. */
207 readonly lostTime: Seconds;
208 /** Ist der Plan in sich schaltbar? */
209 readonly feasible: boolean;
210 readonly notes: readonly PlanNote[];
211 readonly defaults: RilsaDefaults;
212 }
213
214 /** Zwischenzeit einer Beziehung samt Herkunft. */
215 export interface ResolvedIntergreen {
216 readonly conflictId: string;
217 readonly fromId: string;
218 readonly toId: string;
219 readonly value: Seconds;
220 /** Von Hand vorgegeben oder gerechnet? */
221 readonly source: 'berechnet' | 'vorgegeben';
222 readonly calculation: IntergreenResult | null;
223 }
224
225 /**
226 * Schluessel eines gerichteten Kennungspaares.
227 *
228 * SIE HAT ZWEI AUFGABEN, und wer sie aendert, aendert beide.
229 *
230 * ERSTENS ist sie der Schluessel der Zwischenzeitenkarte dieses Plans
231 * (`intergreens`) und der Karten, die daran haengen: `intergreenBetween` hier,
232 * `checkIntergreenGaps` und der ungerichtete Paarschluessel in
233 * validation/engine.ts, die Zwischenzeitenmatrix in Ansicht, Ausdruck und CSV.
234 * Fallen zwei Paare auf denselben Schluessel, bekommt eine Beziehung die
235 * Zwischenzeit der anderen.
236 *
237 * ZWEITENS ist sie das AUSWAHLMERKMAL der vermessenen Wege: `wegSchluessel`
238 * (src/domain/geometrie/ableitung.ts, Fassung 5.11.0) bildet damit den
239 * Schluessel eines Strompaares aus den beiden PLANLINIENKENNUNGEN, und die
240 * Auswahl des Anwenders im Lageplan geht als Menge dieser Schluessel durch
241 * `uebernimmWege`. Wer hier die Bildung aendert, entscheidet also mit, WELCHER
242 * Weg uebernommen wird: Fallen zwei Strompaare auf denselben Schluessel, wird
243 * ein nicht angehaktes Paar mitgeschrieben und ein angehaktes bleibt
244 * ungeschrieben. Der Schluessel wird dort nie zerlegt, sondern nur verglichen -
245 * er muss eindeutig sein, nicht umkehrbar.
246 *
247 * EINDEUTIG, AUCH BEI EINER KENNUNG MIT "|" (Fassung 5.10.0): Hier stand
248 * `${fromId}|${toId}` ohne jede Absicherung. Eine Signalgruppenkennung kam
249 * damals ungeprueft aus der Datei - das Einlesen beanstandete nur die leere und
250 * die doppelt vergebene, kein Zeichenrepertoire -, und fuer die Kennungen
251 * "a|b", "c", "a" und "b|c" ergab das zweimal denselben Schluessel "a|b|c". Die
252 * Karte behielt den zuletzt geschriebenen Eintrag, die andere Beziehung erhielt
253 * die FREMDE Zwischenzeit, und war die kuerzer, wurde der Phasenuebergang zu
254 * kurz bemessen - die gefaehrliche Richtung. Eine Gegenprobe gab es nicht:
255 * `checkIntergreenGaps` schlaegt unter demselben Schluessel nach.
256 *
257 * `maskiereKennung` macht das "|" in der Kennung unkenntlich; der verbindende
258 * Strich ist danach der einzige im Schluessel, die Zuordnung damit umkehrbar.
259 * Kennungen aus `createId` (a-z, 0-9, "-") bleiben unveraendert, der Schluessel
260 * liest sich fuer sie weiterhin als "fromId|toId".
261 *
262 * SEIT DEM ZEICHENREPERTOIRE (`ZULAESSIGE_KENNUNG` in model/schema.ts,
263 * Fassung 5.12.0) kann eine eingelesene Kennung weder "|" noch "~"
264 * enthalten; die Maskierung laeuft fuer sie leer. Sie bleibt trotzdem stehen:
265 * Sie kostet nichts, und sie haelt den Schluessel auch dann eindeutig, wenn das
266 * Repertoire eines Tages ein weiteres Zeichen aufnimmt.
267 */
268 export function intergreenKey(fromId: string, toId: string): string {
269 return `${maskiereKennung(fromId)}|${maskiereKennung(toId)}`;
270 }
271
272 /**
273 * Maskiert die Zeichen, die den Schluessel mehrdeutig machen: "~" wird zu
274 * "~~", "|" zu "~s". Beides ist eindeutig umkehrbar, und im Ergebnis kommt
275 * kein "|" mehr vor.
276 */
277 function maskiereKennung(id: string): string {
278 return id.includes('~') || id.includes('|') ? id.replaceAll('~', '~~').replaceAll('|', '~s') : id;
279 }
280
281 /**
282 * Vermerk, den jede Ausgabe der Umlaufzeit neben den Wert setzt, wenn er kein
283 * Bemessungsergebnis ist - EIN Wortlaut fuer Ansicht, Ausdruck und CSV.
284 *
285 * Anwendertext, deshalb mit Gedankenstrich - wie jeder andere Anzeigetext des
286 * Programms; das PDF setzt ihn beim Drucken selbst in einen Bindestrich um
287 * (toWinAnsi). Mit einem Bindestrich hier las sich die Kennzahl gemischt:
288 * "120 s – nicht bemessbar (uebersaettigt) - Ersatzwert".
289 */
290 export const UMLAUFZEIT_ERSATZWERT_VERMERK = 'nicht bemessbar (übersättigt) – Ersatzwert';
291
292 /**
293 * Ist die Umlaufzeit dieses Plans nur ein Ersatzwert?
294 *
295 * KORREKTUR (Fassung 5.4.0, Befund C11): Bei Uebersaettigung liefert
296 * die Umlaufzeitermittlung die Obergrenze als Ersatzwert (cycle.ts, bounded
297 * 'uebersaettigt'), damit der Plan eine Zahl hat - die groesste zulaessige
298 * Umlaufzeit, also die Schranke der Anlagenart oder eine engere eigene
299 * Vorgabe (siehe anlagenartGrenzen). Bis dahin trug der Plan "120 s" wie ein
300 * Ergebnis - und Kennzahl, Ausdruck und CSV druckten die Zahl nackt, waehrend
301 * nur der Pruefbericht sagte, dass sie keine ist. Jede Stelle, die
302 * plan.cycleTime ausgibt, hat hier zu fragen und den Wert ueber
303 * umlaufzeitMitVermerk zu kennzeichnen.
304 *
305 * Bei fester Vorgabe (cycleResult null) antwortet die Funktion immer mit
306 * "nein" - auch dann, wenn resolveCycleTime eine fehlende oder zu kurze
307 * Handvorgabe durch die Mindestumlaufzeit ersetzt hat. Auch das ist ein
308 * Ersatzwert, aber einer mit eigener Fehler-Notiz (umlaufzeit-fehlt,
309 * umlaufzeit-zu-kurz) und Sperre; der Vermerk hier nennt die Uebersaettigung
310 * beim Namen und passt auf jenen Fall nicht.
311 */
312 export function umlaufzeitIstErsatzwert(plan: Pick<SignalPlan, 'cycleResult'>): boolean {
313 return plan.cycleResult?.bounded === 'uebersaettigt';
314 }
315
316 /**
317 * Haengt den Ersatzwert-Vermerk an eine bereits formatierte Umlaufzeit
318 * ("120 s" -> "120 s – nicht bemessbar (uebersaettigt) – Ersatzwert"), wenn
319 * der Plan nur einen Ersatzwert fuehrt; sonst kommt die Zahl unveraendert
320 * zurueck. Die Zahl formatiert der Aufrufer (Anzeige, Ausdruck, Zeichnung
321 * haben je ihre Formatierung), das Zusammenfuegen steht nur hier - vor der
322 * Fassung 5.4.0 stand es dreimal gleichlautend in Statuszeile, Signalzeitenplan
323 * und Ausdruck.
324 */
325 export function umlaufzeitMitVermerk(plan: Pick<SignalPlan, 'cycleResult'>, zahl: string): string {
326 return umlaufzeitIstErsatzwert(plan) ? `${zahl} – ${UMLAUFZEIT_ERSATZWERT_VERMERK}` : zahl;
327 }
328
329 /**
330 * Die fuer ein Projekt geltenden Schranken.
331 *
332 * WELCHE SCHRANKE GEWINNT - DIE ANLAGENART ODER DIE VORGABENVERWALTUNG?
333 *
334 * Die Anlagenart bestimmt, welcher Bereich fachlich ueberhaupt in Frage kommt.
335 * Die Vorgabenverwaltung (rilsa/settings.ts) bestimmt, was der Anwender in
336 * DIESEM Projekt zulassen will. Der Anwender darf den Bereich der Anlagenart
337 * einengen, aber nicht aufweiten: Eine Baustellenampel wird nicht dadurch
338 * vertretbar, dass jemand 400 s eintraegt, und ein Knotenpunkt wird es nicht
339 * durch 200 s. Deshalb der engere der beiden Werte.
340 *
341 * Entscheidend ist die zweite Haelfte der Regel: Nur eine AUSDRUECKLICH
342 * eingetragene Vorgabe zaehlt als Anwenderwille. `defaults.cycleTime` fuehrt
343 * immer einen Wert - ohne Eintrag ist es RILSA_DEFAULTS, und das ist die
344 * Knotenpunktzeile des Regelwerks (30 bis 120 s). Wuerde sie als Vorgabe
345 * gelten, schnitte sie jede Arbeitsstelle wieder bei 120 s ab: genau der
346 * Zustand, wegen dem eine einstreifige Verkehrsfuehrung bisher nicht rechenbar
347 * war. Ob eingetragen wurde, steht nur in den Rohvorgaben des Projekts.
348 *
349 * Uebernommen wird dann aber der von settings.ts bereits gepruefte Wert aus
350 * `defaults`, nicht die Roheingabe. Sonst rechnete der Plan mit einer Zahl, die
351 * die Vorgabenpruefung im selben Pruefbericht als unzulaessig meldet.
352 */
353 export function anlagenartGrenzen(
354 project: Project,
355 defaults: RilsaDefaults,
356 notes: CalculationNote[] = [],
357 ): AnlagenartGrenzen {
358 const grenzen = ANLAGENART_GRENZEN[project.anlagenart];
359 const eingetragen = project.settings.rilsa;
360
361 const min =
362 eingetragen.cycleTimeMin !== undefined
363 ? Math.max(grenzen.cycleTime.min, defaults.cycleTime.min)
364 : grenzen.cycleTime.min;
365 const max =
366 eingetragen.cycleTimeMax !== undefined
367 ? Math.min(grenzen.cycleTime.max, defaults.cycleTime.max)
368 : grenzen.cycleTime.max;
369
370 if (min > max) {
371 // Kann mit den heutigen Zahlen nicht eintreten, waere aber der eine Fall,
372 // in dem ein stiller Rueckfall gefaehrlich ist: Ein leerer Bereich liesse
373 // jede Umlaufzeit sowohl "zu kurz" als auch "zu lang" sein.
374 notes.push({
375 severity: 'fehler',
376 code: 'umlaufzeit-grenzen-widerspruch',
377 message:
378 `Die vorgegebenen Umlaufzeitgrenzen ergeben zusammen mit den Schranken der Anlagenart ` +
379 `einen leeren Bereich (${formatSeconds(min)} bis ${formatSeconds(max)} s). Es gelten die ` +
380 `Schranken der Anlagenart, ${formatSeconds(grenzen.cycleTime.min)} bis ` +
381 `${formatSeconds(grenzen.cycleTime.max)} s.`,
382 });
383 return grenzen;
384 }
385
386 // Der Regelbereich ist ein Hinweis, keine Schranke. Ein ausdruecklich
387 // eingetragener Wert gilt hier unveraendert; er wird lediglich in den
388 // zulaessigen Bereich gelegt, damit die Meldung "ausserhalb des ueblichen
389 // Bereichs" nicht auf einen Wert zeigt, der ohnehin nicht erreichbar ist.
390 const recommendedMin =
391 eingetragen.cycleTimeRecommendedMin !== undefined
392 ? defaults.cycleTime.recommendedMin
393 : grenzen.cycleTime.recommendedMin;
394 const recommendedMax =
395 eingetragen.cycleTimeRecommendedMax !== undefined
396 ? defaults.cycleTime.recommendedMax
397 : grenzen.cycleTime.recommendedMax;
398
399 return {
400 ...grenzen,
401 cycleTime: {
402 min,
403 max,
404 recommendedMin: clamp(recommendedMin, min, max),
405 recommendedMax: clamp(recommendedMax, min, max),
406 },
407 };
408 }
409
410 /** Baut den Signalzeitenplan. Reine Funktion ohne Nebenwirkungen. */
411 export function buildSignalPlan(project: Project): SignalPlan {
412 const notes: PlanNote[] = [];
413 // Mit der Anlagenart des Projekts: Die Umlaufzeit-Vorgaben werden gegen
414 // DEREN Schranken geprueft (120 s Knotenpunkt, 300 s einstreifig; Befund
415 // C10). Ohne sie gaelte der Vorgabewert 'knotenpunkt' und kappte jeden
416 // einstreifigen Plan mit 250 s auf 120 s.
417 const resolved = resolveRilsaSettings(project.settings.rilsa, undefined, project.anlagenart);
418 notes.push(...resolved.notes);
419 const defaults = resolved.defaults;
420
421 // Die Schranken stehen vor jeder Rechnung fest: An ihnen haengt die
422 // Umlaufzeitermittlung, nicht nur die Beurteilung des Ergebnisses.
423 const grenzen = anlagenartGrenzen(project, defaults, notes);
424
425 // Vor der Rechnung mit den Verkehrsstaerken: Ein Schwerverkehrsanteil
426 // ausserhalb von 0 bis 100 % wird begrenzt UND gemeldet.
427 notes.push(...schwerverkehrNotizen(project));
428
429 const intergreens = resolveIntergreens(project, defaults, notes);
430 const phases = orderedPhases(project);
431
432 if (phases.length === 0) {
433 notes.push({
434 severity: 'fehler',
435 code: 'keine-phasen',
436 message:
437 'Es ist keine Phase angelegt. Ohne Phasen lässt sich kein Signalzeitenplan aufbauen.',
438 });
439 return emptyPlan(project, defaults, grenzen, intergreens, notes);
440 }
441
442 const transitions = computeTransitionRequirements(project, phases, intergreens);
443 const transitionTime = transitions.reduce((sum, t) => sum + t.duration, 0);
444 const lostTime = transitionTime + phases.length * defaults.startUpLostTime;
445
446 const phaseFlowRatios = phases.map((phase) => phaseFlowRatio(project, phase, defaults));
447 const phaseMinGreens = phases.map((phase) => phaseMinGreen(project, phase, defaults));
448 const hoechstfreigabezeiten = phases.map((phase, index) =>
449 phaseMaxGreen(
450 project,
451 phase,
452 defaults,
453 phaseMinGreens[index] ?? 0,
454 festeFreigabezeit(phase, phaseMinGreens[index] ?? 0),
455 notes,
456 ),
457 );
458 const phaseMaxGreens = hoechstfreigabezeiten.map((h) => h.wert);
459 notes.push(
460 ...vorgabeHoechstfreigabezeitNotiz(phases, phaseMinGreens, hoechstfreigabezeiten, defaults),
461 );
462 const minimumCycle = phaseMinGreens.reduce((a, b) => a + b, 0) + transitionTime;
463
464 const { cycleTime, cycleResult, cycleComparison } = resolveCycleTime(
465 project,
466 { lostTime, criticalFlowRatios: phaseFlowRatios, minimumCycle, step: defaults.cycleTime.step },
467 defaults,
468 grenzen,
469 notes,
470 );
471
472 const split = distributeGreenTimes(
473 cycleTime,
474 phases.map((phase, index) => {
475 const manual = phase.manualGreen;
476 const min = phaseMinGreens[index] ?? 0;
477 // Eine von Hand gesetzte Freigabezeit wird als feste Vorgabe behandelt:
478 // Unter- und Obergrenze fallen zusammen. Dieselbe Bedingung entscheidet
479 // in festeFreigabezeit darueber, ob die Hoechstfreigabezeit der
480 // Signalgruppen ueberhaupt zur Wirkung kommt - sie steht dort einmal.
481 const fest = festeFreigabezeit(phase, min);
482 if (fest !== null) {
483 return {
484 id: phase.id,
485 weight: phaseFlowRatios[index] ?? 0,
486 minGreen: fest,
487 maxGreen: fest,
488 };
489 }
490 if (manual !== null && isFiniteNumber(manual) && manual < min) {
491 notes.push({
492 severity: 'fehler',
493 code: 'phase-freigabezeit-zu-kurz',
494 message:
495 `Für "${phase.name}" ist eine Freigabezeit von ${formatSeconds(manual)} s vorgegeben, ` +
496 `die Mindestfreigabezeit beträgt jedoch ${formatSeconds(min)} s. Angesetzt wird die ` +
497 'Mindestfreigabezeit.',
498 });
499 return { id: phase.id, weight: phaseFlowRatios[index] ?? 0, minGreen: min, maxGreen: min };
500 }
501 return {
502 id: phase.id,
503 weight: phaseFlowRatios[index] ?? 0,
504 minGreen: min,
505 maxGreen: phaseMaxGreens[index] ?? defaults.maxGreen,
506 };
507 }),
508 transitions.map((t) => t.duration),
509 );
510 notes.push(...split.notes);
511
512 const durations = phases.map((phase) => split.phases.find((p) => p.id === phase.id)?.green ?? 0);
513
514 // Startzeitpunkte: Phase, Uebergang, Phase, Uebergang ...
515 const phaseStarts: Seconds[] = [];
516 const transitionStarts: Seconds[] = [];
517 let cursor = 0;
518 for (let i = 0; i < phases.length; i += 1) {
519 phaseStarts.push(cursor);
520 cursor += durations[i] ?? 0;
521 transitionStarts.push(cursor);
522 cursor += transitions[i]?.duration ?? 0;
523 }
524 const actualCycle = cursor;
525
526 const plannedPhases: PlannedPhase[] = phases.map((phase, index) => ({
527 id: phase.id,
528 name: phase.name,
529 start: roundTo(phaseStarts[index] ?? 0, 3),
530 duration: roundTo(durations[index] ?? 0, 3),
531 signalGroupIds: phase.signalGroupIds,
532 flowRatio: roundTo(phaseFlowRatios[index] ?? 0, 4),
533 }));
534
535 const plannedTransitions: PhaseTransition[] = transitions.map((t, index) => ({
536 ...t,
537 start: roundTo(transitionStarts[index] ?? 0, 3),
538 }));
539
540 const groups = buildGroupWindows(
541 project,
542 phases,
543 phaseStarts,
544 durations,
545 transitions,
546 transitionStarts,
547 intergreens,
548 actualCycle,
549 defaults,
550 notes,
551 );
552
553 if (Math.abs(actualCycle - cycleTime) > 0.01) {
554 notes.push({
555 severity: 'hinweis',
556 code: 'umlaufzeit-angepasst',
557 message:
558 `Die Summe aus Freigabe- und Übergangszeiten ergibt ${formatSeconds(actualCycle)} s. ` +
559 'Dieser Wert ist maßgebend.',
560 });
561 }
562
563 return {
564 cycleTime: roundTo(actualCycle, 3),
565 cycleResult,
566 cycleComparison,
567 anlagenart: project.anlagenart,
568 grenzen,
569 phases: plannedPhases,
570 transitions: plannedTransitions,
571 groups,
572 intergreens,
573 transitionTime: roundTo(transitionTime, 3),
574 lostTime: roundTo(lostTime, 3),
575 feasible: split.feasible && actualCycle > 0,
576 notes,
577 defaults,
578 };
579 }
580
581 // --- Zwischenzeiten ---------------------------------------------------------
582
583 function resolveIntergreens(
584 project: Project,
585 defaults: RilsaDefaults,
586 notes: CalculationNote[],
587 ): Map<string, ResolvedIntergreen> {
588 const result = new Map<string, ResolvedIntergreen>();
589
590 /*
591 * EINE Stelle entscheidet ueber das Verfahren, und beide Aufrufe nehmen sie:
592 * die Rechnung selbst und die Gegenprobe zu einer von Hand vorgegebenen
593 * Zwischenzeit. Zwei Aufbauten nebeneinander liefen beim naechsten Feld
594 * auseinander - dieselbe Begruendung wie bei `raeumenderStrom`.
595 *
596 * An einer einstreifigen Verkehrsfuehrung gilt fuer den
597 * KRAFTFAHRZEUGVERKEHR das eigene Verfahren des Abschnitts 5.2
598 * (Engstellensignalisierung); fuer Rad, Fussgaenger und OePNV bleibt es bei
599 * Abschnitt 2, weil die RiLSA fuer sie in 5.2 nichts anderes sagt. Der
600 * Radverkehr kommt dort vor - er wird mit 18 km/h massgebend, wenn die
601 * Engstelle zu eng ist -, aber als Bedingung fuer den KFZ-Raeumvorgang;
602 * das Programm kann die Enge nicht messen und meldet sie deshalb
603 * (`einstreifig.radverkehr-massgebend` im Pruefbericht).
604 */
605 const engstelle = project.anlagenart === 'einstreifig';
606 const zwischenzeit = (
607 from: SignalGroup,
608 conflict: Conflict,
609 to: SignalGroup,
610 ): IntergreenResult => {
611 const clearing = raeumenderStrom(from, conflict, project.settings.ueberfahrzeitAnsatz);
612 const entering = einfahrenderStrom(to, conflict);
613 if (engstelle && from.mode === 'kfz') {
614 return computeEngstellenIntergreen(clearing, entering, defaults);
615 }
616 return computeIntergreen(clearing, entering, defaults);
617 };
618
619 for (const conflict of project.conflicts) {
620 const from = findSignalGroup(project, conflict.fromId);
621 const to = findSignalGroup(project, conflict.toId);
622 if (!from || !to) continue;
623
624 if (
625 conflict.manualIntergreen !== null &&
626 isFiniteNumber(conflict.manualIntergreen) &&
627 conflict.manualIntergreen >= 0
628 ) {
629 const vorgegeben = Math.ceil(conflict.manualIntergreen);
630
631 /*
632 * Ganze Sekunden - wie jede gerechnete Zwischenzeit (computeIntergreen
633 * rundet auf). Vor der Fassung 5.4.0 geschah das Aufrunden stumm: 4,2 s
634 * eingetragen, 5 s gerechnet, und nirgends stand es. Die Zahl im Feld
635 * bleibt, die Notiz sagt, womit gerechnet wird. Ein Hinweis, kein
636 * Fehler - aufgerundet ist die sichere Seite.
637 */
638 if (vorgegeben !== conflict.manualIntergreen) {
639 notes.push({
640 severity: 'hinweis',
641 code: 'zwischenzeit-vorgabe-aufgerundet',
642 konfliktId: conflict.id,
643 message:
644 `Zwischenzeit ${from.name} nach ${to.name}: Die von Hand vorgegebene Zwischenzeit ` +
645 `${formatSeconds(conflict.manualIntergreen)} s wird auf ganze Sekunden aufgerundet; ` +
646 `angesetzt werden ${vorgegeben} s.`,
647 });
648 }
649
650 /*
651 * Gegenprobe zur Vorgabe.
652 *
653 * Die vorgegebene Zwischenzeit ersetzt die Rechnung vollstaendig -
654 * computeIntergreen wird gar nicht erst gerufen. Damit liefen auch alle
655 * Schranken ins Leere, die fuer die Einzelwerte gelten: die Obergrenze
656 * der Raeumgeschwindigkeit, die Gelbzeit als kuerzeste Ueberfahrzeit. In
657 * der Zwischenzeitenansicht stehen beide Felder nebeneinander; wer
658 * kuerzen wollte, nahm einfach das Feld ohne Schranke. Die Vorgabe bleibt
659 * zulaessig - der Planer kann einen abgestimmten Wert eintragen -, aber
660 * sie darf den Rechenwert nicht mehr stillschweigend unterschreiten.
661 *
662 * Ohne Raeumweg gibt es keine Vergleichsgrundlage; dann wird nicht
663 * verglichen.
664 */
665 if (conflict.clearingDistance > 0) {
666 const gegenprobe = zwischenzeit(from, conflict, to);
667 if (vorgegeben < gegenprobe.intergreen) {
668 notes.push({
669 severity: 'warnung',
670 code: 'zwischenzeit-vorgabe-unter-rechenwert',
671 konfliktId: conflict.id,
672 message:
673 `Zwischenzeit ${from.name} nach ${to.name}: Die vorgegebene Zwischenzeit ` +
674 `${vorgegeben} s liegt unter den aus Räum- und Einfahrweg gerechneten ` +
675 `${gegenprobe.intergreen} s. Die Abweichung ist zu begründen.`,
676 });
677 }
678 }
679
680 result.set(intergreenKey(conflict.fromId, conflict.toId), {
681 conflictId: conflict.id,
682 fromId: conflict.fromId,
683 toId: conflict.toId,
684 value: vorgegeben,
685 source: 'vorgegeben',
686 calculation: null,
687 });
688 continue;
689 }
690
691 /*
692 * Eine negative oder unlesbare Handvorgabe ist keine Zwischenzeit. Vor der
693 * Fassung 5.4.0 wurde sie stumm auf 0 s gehoben - und 0 s ERSETZTE die
694 * Rechnung: die feindlichen Stroeme durften ohne Zwischenzeit aufeinander
695 * folgen. Jetzt gilt sie als nicht gesetzt: Die Rechnung aus Raeum- und
696 * Einfahrweg tritt an ihre Stelle, und der Bericht meldet einen Fehler
697 * (Sperre), damit der Eintrag berichtigt wird. Das Schema (optionalNum)
698 * faengt Unlesbares beim Einlesen ab; dies ist der zweite Riegel und der
699 * einzige gegen ein negatives Vorzeichen.
700 */
701 if (conflict.manualIntergreen !== null) {
702 notes.push({
703 severity: 'fehler',
704 code: 'zwischenzeit-vorgabe-ungueltig',
705 konfliktId: conflict.id,
706 message:
707 `Zwischenzeit ${from.name} nach ${to.name}: Die von Hand vorgegebene Zwischenzeit ` +
708 `${formatSeconds(conflict.manualIntergreen)} s ist kein zulässiger Wert (kleiner als 0 s ` +
709 'oder keine Zahl). Sie wird nicht übernommen; gerechnet wird aus Räum- und Einfahrweg. ' +
710 'Berichtigen oder entfernen Sie die Vorgabe an der Konfliktbeziehung.',
711 });
712 }
713
714 const calculation = zwischenzeit(from, conflict, to);
715
716 for (const note of calculation.notes) {
717 if (note.severity === 'hinweis') continue;
718 notes.push({
719 ...note,
720 konfliktId: conflict.id,
721 message: `Zwischenzeit ${from.name} nach ${to.name}: ${note.message}`,
722 });
723 }
724
725 result.set(intergreenKey(conflict.fromId, conflict.toId), {
726 conflictId: conflict.id,
727 fromId: conflict.fromId,
728 toId: conflict.toId,
729 value: calculation.intergreen,
730 source: 'berechnet',
731 calculation,
732 });
733 }
734
735 return result;
736 }
737
738 /*
739 * Die beiden Stroeme einer Konfliktbeziehung.
740 *
741 * Als eigene Funktionen, damit die Gegenprobe zu einer vorgegebenen
742 * Zwischenzeit mit genau denselben Werten rechnet wie die Rechnung selbst.
743 * Zwei Aufbauten nebeneinander liefen beim naechsten Feld auseinander.
744 */
745 /**
746 * DIE FAHRBEZIEHUNG DES STROMS, DEM DER RAEUMWEG EINER BEZIEHUNG GEHOERT -
747 * nicht immer die der raeumenden Signalgruppe.
748 *
749 * Eine Signalgruppe kann mehrere Stroeme bedienen; geradeaus und rechts aus
750 * einem Fahrstreifen ist der Regelfall. `uebernimmWege` waehlt dann je
751 * Gruppenpaar das unguenstigste STROMpaar aus und legt dessen Geometrie in die
752 * Beziehung - zusammen mit `raeumbeziehung`, wenn dieser Strom eine andere
753 * Fahrbeziehung hat als seine Gruppe. Fehlt das Feld, gilt die Fahrbeziehung
754 * der Gruppe: Eine Datei aelterer Fassung, ein von Hand erfasster Knotenpunkt
755 * und ein Projekt aus 4.x sagen nichts darueber, welcher Strom den
756 * gespeicherten Weg gefahren ist.
757 *
758 * Zuvor stand in `raeumenderStrom` schlicht `from.movement`.
759 * Damit raeumte der Rechtsabbiegebogen mit der Geradeausgeschwindigkeit -
760 * 10,0 statt 7,0 m/s. An `beispiele/vierarmiger-knotenpunkt.lsap` stand danach
761 * an vier Beziehungen eine Zwischenzeit neben ihrem eigenen Widerspruch:
762 * gedruckte 5 s neben dem Vermerk "(6 s)", nach erneuter Uebernahme aus dem
763 * Lageplan 7 s neben "(9 s)". Zu kurz ist die gefaehrliche Richtung.
764 *
765 * ALS EIGENE, AUSGEFUEHRTE FUNKTION - und zwar aus demselben Grund, aus dem es
766 * `engerRadiusWirktAn` gibt (intergreen.ts): Wer sonst noch wissen muss, mit
767 * welcher Fahrbeziehung diese Beziehung raeumt - Pruefbericht, Konfliktfenster,
768 * Planunterlage -, liest sie hier und baut die Entscheidung nicht ein zweites
769 * Mal nach. Genau daraus ist der Fehler entstanden: Die Uebernahme aus dem
770 * Lageplan entschied nach dem Strom, der Plan nach der Gruppe, und die
771 * gedruckte Zeile widersprach sich selbst.
772 */
773 export function massgebendeRaeumbeziehung(
774 from: Pick<SignalGroup, 'movement'>,
775 conflict: Pick<Conflict, 'raeumbeziehung'>,
776 ): Movement {
777 return conflict.raeumbeziehung ?? from.movement;
778 }
779
780 function raeumenderStrom(
781 from: SignalGroup,
782 conflict: Conflict,
783 ueberfahrzeitAnsatz: UeberfahrzeitAnsatz,
784 ): ClearingStream {
785 return {
786 mode: from.mode,
787 movement: massgebendeRaeumbeziehung(from, conflict),
788 clearingDistance: conflict.clearingDistance,
789 vehicleClass: from.vehicleClass,
790 vZul: from.vZul,
791 ...(conflict.clearingSpeedOverride !== null
792 ? { clearingSpeedOverride: conflict.clearingSpeedOverride }
793 : {}),
794 ...(conflict.crossingTimeOverride !== null
795 ? { crossingTimeOverride: conflict.crossingTimeOverride }
796 : {}),
797 ...(from.reducedMobility ? { reducedMobility: true } : {}),
798 // Halt vor dem Knotenpunkt (RiLSA-Fall 4) - wirkt nur bei OePNV-Stroemen.
799 ...(conflict.haltVorKnoten === true ? { haltVorKnoten: true } : {}),
800 // Enger Innenradius - wirkt nur bei abbiegenden Kfz-Stroemen.
801 ...(conflict.engerRadius === true ? { engerRadius: true } : {}),
802 /*
803 * DIE EINZIGE STELLE, an der die Projektwahl des Ueberfahrzeit-Ansatzes in
804 * die Rechnung eintritt. Sie steht hier und nicht in computeIntergreen,
805 * damit die Gegenprobe zu einer von Hand gesetzten Zwischenzeit mit
806 * demselben Ansatz rechnet wie die Rechnung selbst - der Grund, aus dem es
807 * diese Funktion ueberhaupt gibt.
808 */
809 ueberfahrzeitAnsatz,
810 };
811 }
812
813 function einfahrenderStrom(to: SignalGroup, conflict: Conflict): EnteringStream {
814 return {
815 mode: to.mode,
816 movement: to.movement,
817 enteringDistance: conflict.enteringDistance,
818 ...(conflict.enteringSpeedOverride !== null
819 ? { enteringSpeedOverride: conflict.enteringSpeedOverride }
820 : {}),
821 };
822 }
823
824 /** Zwischenzeit einer Beziehung; 0, wenn kein Konflikt erfasst ist. */
825 export function intergreenBetween(
826 intergreens: ReadonlyMap<string, ResolvedIntergreen>,
827 fromId: string,
828 toId: string,
829 ): Seconds {
830 return intergreens.get(intergreenKey(fromId, toId))?.value ?? 0;
831 }
832
833 // --- Phasenuebergaenge ------------------------------------------------------
834
835 /**
836 * Erste Phase ab `von`, die ueberhaupt eine Signalgruppe fuehrt - vorwaerts
837 * ueber die Umlaufgrenze hinweg. Fuehrt keine Phase eine Gruppe, kommt `von`
838 * zurueck.
839 */
840 function naechstePhaseMitGruppen(phases: readonly Phase[], von: number): number {
841 const n = phases.length;
842 for (let schritt = 0; schritt < n; schritt += 1) {
843 const index = (von + schritt) % n;
844 if (phases[index]!.signalGroupIds.length > 0) return index;
845 }
846 return von;
847 }
848
849 /**
850 * Rang einer Beziehung im Wettstreit um die massgebende Zwischenzeit eines
851 * Phasenuebergangs - vier Stufen, in dieser Reihenfolge verglichen.
852 *
853 * WARUM ES DIESEN RANG GIBT (Fassung 5.24.0). Verglichen wurde bis dahin
854 * allein die aufgerundete Zwischenzeit, mit `>`: Bei Gleichstand blieb das
855 * zuerst gepruefte Paar stehen, und geprueft wird in der Reihenfolge der
856 * Signalgruppen der Folgephase. Am mitgelieferten Beispielknotenpunkt tritt
857 * dieser Gleichstand wirklich auf, und die Wirkung ist sichtbar: Der Satz
858 * "<X> raeumt -> <Y> faehrt ein" steht in der Uebergangstabelle der
859 * Planunterlage. Wer in der Phasenansicht eine Signalgruppe abwaehlte und
860 * sofort wieder anwaehlte, ruecke sie ans Ende der Liste - und in der
861 * gedruckten Unterlage stand danach ein anderes Paar, bei Byte fuer Byte
862 * demselben Plan.
863 *
864 * DIE ERSTEN DREI STUFEN SIND FACHLICH. Die aufgerundete Zwischenzeit ist die
865 * Zahl, die im Plan steht; der ungerundete Wert und der Raeumweg sagen, welche
866 * der gleich aussehenden Beziehungen die anspruchsvollere ist - genau die
867 * gehoert benannt, denn sie bleibt massgebend, wenn sich Gelbzeit oder
868 * zulaessige Geschwindigkeit spaeter aendern.
869 *
870 * DIE VIERTE IST ES NICHT, und das steht hier, damit niemand sie dafuer haelt:
871 * Sind zwei Beziehungen auch im ungerundeten Wert und im Raeumweg gleich, gibt
872 * es nichts mehr, was sie unterscheidet. Dann entscheidet der Name - nicht
873 * weil er etwas bedeutete, sondern weil er sich nicht verschiebt, wenn jemand
874 * eine Signalgruppe neu zuordnet. Eine willkuerliche Wahl laesst sich hier
875 * nicht vermeiden; eine WANDERNDE laesst sich vermeiden.
876 */
877 interface Rangfolge {
878 readonly zwischenzeit: Seconds;
879 readonly roh: Seconds;
880 readonly raeumweg: Meters;
881 readonly name: string;
882 }
883
884 function rangDerBeziehung(
885 intergreens: ReadonlyMap<string, ResolvedIntergreen>,
886 fromId: string,
887 toId: string,
888 wert: Seconds,
889 vonName: string,
890 nachName: string,
891 ): Rangfolge {
892 const eintrag = intergreens.get(intergreenKey(fromId, toId));
893 return {
894 zwischenzeit: wert,
895 // Eine von Hand vorgegebene Zwischenzeit fuehrt keine Rechnung; dann ist
896 // der vorgegebene Wert selbst das Genaueste, was vorliegt.
897 roh: eintrag?.calculation?.raw ?? wert,
898 raeumweg: eintrag?.calculation?.clearingPath ?? 0,
899 name: `${vonName}${nachName}`,
900 };
901 }
902
903 /** Positiv, wenn `a` vor `b` liegt. */
904 function vergleicheRang(a: Rangfolge, b: Rangfolge): number {
905 if (a.zwischenzeit !== b.zwischenzeit) return a.zwischenzeit - b.zwischenzeit;
906 if (a.roh !== b.roh) return a.roh - b.roh;
907 if (a.raeumweg !== b.raeumweg) return a.raeumweg - b.raeumweg;
908 // Absteigend nach Name waere ebenso beliebig; entscheidend ist allein, dass
909 // dieselbe Eingabe immer dieselbe Antwort gibt.
910 return b.name.localeCompare(a.name, 'de');
911 }
912
913 function computeTransitionRequirements(
914 project: Project,
915 phases: readonly Phase[],
916 intergreens: ReadonlyMap<string, ResolvedIntergreen>,
917 ): Omit<PhaseTransition, 'start'>[] {
918 const result: Omit<PhaseTransition, 'start'>[] = [];
919
920 for (let i = 0; i < phases.length; i += 1) {
921 const from = phases[i]!;
922 // Der Uebergang liegt im Umlauf zwischen dieser und der unmittelbar
923 // folgenden Phase; daran haengen seine Lage und seine Beschriftung.
924 const next = phases[(i + 1) % phases.length]!;
925 /*
926 * BEMESSEN wird dagegen gegen die naechste Phase, die ueberhaupt eine
927 * Signalgruppe fuehrt (Befund F6).
928 *
929 * Eine Phase ohne Signalgruppe laesst nichts beginnen und nichts enden.
930 * Gegen sie bemessen ergaben BEIDE angrenzenden Uebergaenge 0 s, und weil
931 * eine solche Phase zugleich 0 s Freigabezeit erhaelt, begann die Freigabe
932 * der Folgephase in derselben Sekunde, in der die der Vorphase endete: Die
933 * gerechnete Zwischenzeit fiel ersatzlos weg. Das ist keine fachliche
934 * Beurteilung, die dem Planaufbau abginge - dass eine Phase keine
935 * Signalgruppe fuehrt, sieht er selbst.
936 *
937 * Die volle Zwischenzeit liegt damit im Uebergang, der die belegte Phase
938 * VERLAESST. Der Uebergang aus der leeren Phase heraus bleibt 0 s, weil dort
939 * nichts endet - doppelt gezaehlt wird nichts. Die Dauer der leeren Phase
940 * wird nicht angerechnet; das liegt auf der sicheren Seite.
941 */
942 const to = phases[naechstePhaseMitGruppen(phases, (i + 1) % phases.length)]!;
943 const ending = from.signalGroupIds.filter((id) => !to.signalGroupIds.includes(id));
944 const starting = to.signalGroupIds.filter((id) => !from.signalGroupIds.includes(id));
945
946 let duration = 0;
947 let critical: PhaseTransition['critical'] = null;
948 let rangDesSiegers: Rangfolge | null = null;
949
950 for (const toId of starting) {
951 for (const fromId of ending) {
952 const value = intergreenBetween(intergreens, fromId, toId);
953 /*
954 * NULL SEKUNDEN MACHEN KEINE MASSGEBENDE BEZIEHUNG, und das ist keine
955 * Feinheit: `critical === null` heisst "keine Beziehung mit einer
956 * Zwischenzeit ueber 0 s", und daran haengen die Uebergangszeile der
957 * Ansicht und der Fehler zwischenzeiten.uebergang-ungesichert. Bis
958 * 5.23.0 stand hier `value > duration` mit `duration` ab 0, und die
959 * Bedingung fiel darin nebenbei ab. Beim Umbau auf den Rang ist sie
960 * herausgefallen, und drei Wachen haben es gemeldet - sie steht
961 * deshalb jetzt fuer sich.
962 */
963 if (value <= 0) continue;
964 if (value < duration) continue;
965 const fromGroup = findSignalGroup(project, fromId);
966 const toGroup = findSignalGroup(project, toId);
967 const rang = rangDerBeziehung(
968 intergreens,
969 fromId,
970 toId,
971 value,
972 fromGroup?.name ?? fromId,
973 toGroup?.name ?? toId,
974 );
975 if (rangDesSiegers !== null && vergleicheRang(rang, rangDesSiegers) <= 0) continue;
976 duration = value;
977 rangDesSiegers = rang;
978 critical = {
979 fromGroupId: fromId,
980 fromGroupName: fromGroup?.name ?? fromId,
981 toGroupId: toId,
982 toGroupName: toGroup?.name ?? toId,
983 intergreen: value,
984 };
985 }
986 }
987
988 /*
989 * Hier stand eine zweite Meldung zu genau derselben Bedingung.
990 *
991 * Sie erschien neben zwischenzeiten.uebergang-ungesichert der Pruefung - zu
992 * demselben Uebergang, mit anderem Wortlaut, anderer Kategorie
993 * (Signalzeitenplan statt Zwischenzeiten) und anderer Schwere (Warnung
994 * statt Fehler). Zwei Meldungen zur selben Sache, die sich in der Bewertung
995 * widersprechen, sind fuer den Bearbeiter nicht aufloesbar.
996 *
997 * Zustaendig ist die Pruefung: Sie kennt die erfassten Konfliktbeziehungen
998 * und kann deshalb "gar nichts erfasst" von "erfasst und rechnerisch 0 s"
999 * unterscheiden. Der Planaufbau kann das nicht.
1000 */
1001
1002 result.push({ fromPhaseId: from.id, toPhaseId: next.id, duration, critical });
1003 }
1004
1005 return result;
1006 }
1007
1008 // --- Freigabezeitfenster je Signalgruppe ------------------------------------
1009
1010 function buildGroupWindows(
1011 project: Project,
1012 phases: readonly Phase[],
1013 phaseStarts: readonly Seconds[],
1014 durations: readonly Seconds[],
1015 transitions: readonly Omit<PhaseTransition, 'start'>[],
1016 transitionStarts: readonly Seconds[],
1017 intergreens: ReadonlyMap<string, ResolvedIntergreen>,
1018 cycleTime: Seconds,
1019 defaults: RilsaDefaults,
1020 notes: PlanNote[],
1021 ): PlannedSignalGroup[] {
1022 const n = phases.length;
1023
1024 return project.signalGroups.map((group) => {
1025 const membership = phases.map((phase) => phase.signalGroupIds.includes(group.id));
1026 const mindestfreigabezeit = mindestfreigabezeitDerGruppe(project, group, defaults);
1027 const times = resolveGroupTimes(group, mindestfreigabezeit, defaults);
1028 notes.push(...furtNotizen(group, mindestfreigabezeit));
1029 notes.push(...vorgabeNotizen(group, mindestfreigabezeit));
1030 const greens: GreenInterval[] = [];
1031
1032 if (membership.every((m) => m)) {
1033 // Dauerfreigabe ueber den gesamten Umlauf.
1034 greens.push({ start: 0, duration: cycleTime });
1035 } else if (membership.some((m) => m)) {
1036 for (const run of cyclicRuns(membership)) {
1037 const startPhase = run[0]!;
1038 const endPhase = run[run.length - 1]!;
1039 const previousIndex = (startPhase - 1 + n) % n;
1040 const transitionStart = transitionStarts[previousIndex] ?? 0;
1041 const transitionDuration = transitions[previousIndex]?.duration ?? 0;
1042
1043 /*
1044 * Die Gruppe erhaelt Freigabe, sobald ihre eigene Zwischenzeit
1045 * abgelaufen ist - nicht erst nach der laengsten des Uebergangs.
1046 *
1047 * MIT EINEM VORBEHALT (Befund F6): Das gilt nur, wenn die unmittelbare
1048 * Vorphase ueberhaupt Signalgruppen fuehrt. Ist sie leer, endet dort
1049 * keine Gruppe, `required` bleibt 0 - und weil der Uebergang IN die
1050 * leere Phase bereits die volle Zwischenzeit traegt, laufen alle
1051 * Gruppen der Folgephase gemeinsam erst nach dieser vollen
1052 * Uebergangszeit an, auch die, die gar keine Zwischenzeit fordern. Das
1053 * ist die sichere, aber unwirtschaftliche Seite.
1054 */
1055 const ending = phases[previousIndex]!.signalGroupIds.filter(
1056 (id) => !phases[startPhase]!.signalGroupIds.includes(id),
1057 );
1058 let required = 0;
1059 for (const fromId of ending) {
1060 required = Math.max(required, intergreenBetween(intergreens, fromId, group.id));
1061 }
1062
1063 const rawStart = transitionStart + Math.min(required, transitionDuration);
1064 /*
1065 * ZUERST FALTEN, DANN MESSEN (Fassung 5.10.0): Beginn und Dauer
1066 * entstehen aus DEMSELBEN Wert. Ein Umlauf-Ueberlauf entsteht, wenn ein
1067 * Fenster ueber den Umlaufbeginn hinweg reicht; der Beginn wird dann in
1068 * den Umlauf zurueckgefaltet und die Dauer ueber die Umlaufgrenze
1069 * hinweg gemessen.
1070 *
1071 * Bis dahin wurde nur der Beginn gefaltet, die Dauer aber gegen den
1072 * ungefalteten `rawStart` gemessen. Endet der Lauf an der Umlaufgrenze
1073 * - alle folgenden Phasen leer (0 s) und der Uebergang aus der letzten
1074 * belegten Phase 0 s -, ist rawStart == end == tU: Der
1075 * Nicht-Ueberlauf-Zweig rechnete 0 s statt des ganzen Umlaufs. Die
1076 * Phasentabelle wies 30 s aus, die Signalgruppenzeile 0 s, und daraus
1077 * entstanden Scheinbefunde (Mindestfreigabezeit unterschritten,
1078 * Auslastungsgrad unendlich).
1079 *
1080 * Fuer jeden bisher richtigen Fall aendert die Faltung nichts:
1081 * rawStart liegt konstruktionsbedingt in [0, tU], und unterhalb von tU
1082 * ist sie die Identitaet.
1083 */
1084 const start = modulo(rawStart, cycleTime);
1085 const end = (phaseStarts[endPhase] ?? 0) + (durations[endPhase] ?? 0);
1086 const duration = end >= start ? end - start : cycleTime - start + end;
1087 greens.push({
1088 start: roundTo(start, 3),
1089 duration: roundTo(duration, 3),
1090 });
1091 }
1092 }
1093
1094 const totalGreen = greens.reduce((sum, g) => sum + g.duration, 0);
1095 const minGreen = times.minGreen;
1096
1097 if (greens.length > 0 && totalGreen < minGreen) {
1098 notes.push({
1099 severity: 'fehler',
1100 code: 'mindestfreigabezeit-unterschritten',
1101 signalGroupId: group.id,
1102 message:
1103 `Die Signalgruppe "${group.name}" erhält ${formatSeconds(totalGreen)} s Freigabezeit, ` +
1104 `mindestens erforderlich sind ${formatSeconds(minGreen)} s` +
1105 (mindestfreigabezeit.massgebend === 'furt'
1106 ? ' (aus der Furtlänge, siehe den Hinweis zur Mindestfreigabezeit).'
1107 : '.'),
1108 });
1109 }
1110 if (greens.length === 0) {
1111 notes.push({
1112 severity: 'warnung',
1113 code: 'signalgruppe-ohne-phase',
1114 signalGroupId: group.id,
1115 message: `Die Signalgruppe "${group.name}" ist keiner Phase zugeordnet und erhält keine Freigabezeit.`,
1116 });
1117 }
1118
1119 const verfahren = project.settings.serviceLevelScale;
1120 const demand = demandFor(project, group.id);
1121 const verkehrsstaerke = erfassteVerkehrsstaerke(demand);
1122 const sperrzeiten = sperrzeitenAusFenstern(greens, cycleTime);
1123
1124 /*
1125 * KORREKTUR (Fassung 5.4.0, Befund B4): Fussgaenger und
1126 * Radverkehr werden nach HBS 2015 nicht ueber Saettigungsverkehrsstaerke,
1127 * Kapazitaet und mittlere Wartezeit bewertet, sondern ueber die maximale
1128 * Wartezeit (laengste Sperrzeit im Umlauf). Bis dahin liefen Radgruppen
1129 * mit einer Kfz-Saettigungsverkehrsstaerke auf der Kfz-Tafel, und
1130 * Fussgaengergruppen erhielten ueber einen Unendlich-Pfad formal die
1131 * Stufe F. Fuer beide gibt es jetzt weder Kapazitaet noch Auslastungsgrad;
1132 * die Bewertung entsteht aus den Signalzeiten allein und braucht keine
1133 * Verkehrsstaerke.
1134 */
1135 if (group.mode === 'fuss' || group.mode === 'rad') {
1136 const delay =
1137 cycleTime > 0
1138 ? computeFussRadDelay({ cycleTime, sperrzeiten, verfahren, mode: group.mode })
1139 : null;
1140 return {
1141 groupId: group.id,
1142 name: group.name,
1143 mode: group.mode,
1144 color: group.color,
1145 times,
1146 mindestfreigabezeit,
1147 greens,
1148 totalGreen: roundTo(totalGreen, 3),
1149 capacity: null,
1150 delay,
1151 flowRatio: null,
1152 rueckstau: null,
1153 };
1154 }
1155
1156 /*
1157 * Der Schwerverkehrsanteil kommt aus dem Datensatz, die Nachfrage nur aus
1158 * einer ERFASSTEN Verkehrsstaerke (Befunde 12 und 18): Ein Datensatz, der
1159 * allein den Schwerverkehrs- oder Lastzuganteil traegt, ist keine
1160 * Zaehlung. Mit `demand?.volume` entstand daraus ein Auslastungsgrad 0,00,
1161 * und an dem hingen die beiden Meldungen, die die Luecke benennen.
1162 */
1163 const saturation = groupSaturationFlow(group, demand, defaults);
1164 const capacity =
1165 cycleTime > 0
1166 ? computeCapacity(totalGreen, cycleTime, saturation, verkehrsstaerke, {
1167 verfahren,
1168 freigabefenster: greens.length,
1169 maxDegreeOfSaturation: defaults.capacity.maxDegreeOfSaturation,
1170 })
1171 : null;
1172 const delay =
1173 verkehrsstaerke !== undefined && capacity !== null
1174 ? computeDelay({
1175 cycleTime,
1176 greenTime: totalGreen,
1177 capacity: capacity.capacity,
1178 demand: verkehrsstaerke,
1179 verfahren,
1180 mode: group.mode,
1181 abflusszeit: capacity.abflusszeit,
1182 longestRed: sperrzeiten.length > 0 ? Math.max(...sperrzeiten) : 0,
1183 /*
1184 * ALLE SPERRZEITEN, NICHT NUR DIE LAENGSTE (Fassung 5.10.0): Aus
1185 * ihnen bildet computeDelay den Zaehler der Grundwartezeit,
1186 * Summe(r_j^2) / tU. Ohne sie faellt es auf die geschlossene
1187 * Einfensterform zurueck - und die ist bei mehreren
1188 * Freigabezeitfenstern zu gross, bei zwei gleich langen Sperrzeiten
1189 * genau um das Doppelte. `longestRed` daneben ist die maximale
1190 * Wartezeit und eine andere Groesse; sie ersetzt die Uebergabe
1191 * nicht.
1192 *
1193 * Es sind DIESELBEN Sperrzeiten, aus denen die Fensterzahl der
1194 * Abflusszeit stammt (`freigabefenster` oben): Der Zeitzuschlag von
1195 * 1 s je Fenster steckt in beiden, und nur deshalb geht der
1196 * Einfensterfall unveraendert in die geschlossene Form ueber.
1197 */
1198 sperrzeiten,
1199 instationaritaetsfaktor: defaults.capacity.instationaritaetsfaktor,
1200 })
1201 : null;
1202
1203 return {
1204 groupId: group.id,
1205 name: group.name,
1206 mode: group.mode,
1207 color: group.color,
1208 times,
1209 mindestfreigabezeit,
1210 greens,
1211 totalGreen: roundTo(totalGreen, 3),
1212 capacity,
1213 delay,
1214 flowRatio:
1215 verkehrsstaerke !== undefined
1216 ? roundTo(criticalFlowRatio(verkehrsstaerke, saturation), 4)
1217 : null,
1218 /*
1219 * Der Rueckstau als Laenge. Nur dort, wo die Bemessung ihn liefert: NGE
1220 * entsteht allein im HBS-Verfahren, und ohne Verkehrsstaerke oder
1221 * Umlaufzeit gibt es gar keine Wartezeitrechnung. Der Stauraum geht in
1222 * keine Zahl ein - er steht nur daneben.
1223 */
1224 rueckstau:
1225 delay?.rechenweg.reststau === undefined || delay.rechenweg.reststau === null
1226 ? null
1227 : rueckstaubewertung(delay.rechenweg.reststau, group.lanes, group.stauraumlaenge ?? null),
1228 };
1229 });
1230 }
1231
1232 /** Maximale zusammenhaengende Laeufe in einem zyklischen Wahrheitsfeld. */
1233 export function cyclicRuns(membership: readonly boolean[]): number[][] {
1234 const n = membership.length;
1235 if (n === 0) return [];
1236 if (membership.every((m) => m)) return [Array.from({ length: n }, (_, i) => i)];
1237 if (!membership.some((m) => m)) return [];
1238
1239 // Startpunkt suchen, an dem ein Lauf beginnt (Vorgaenger ist false).
1240 const starts: number[] = [];
1241 for (let i = 0; i < n; i += 1) {
1242 if (membership[i] === true && membership[(i - 1 + n) % n] !== true) starts.push(i);
1243 }
1244
1245 return starts.map((start) => {
1246 const run: number[] = [];
1247 let index = start;
1248 while (membership[index] === true && run.length < n) {
1249 run.push(index);
1250 index = (index + 1) % n;
1251 }
1252 return run;
1253 });
1254 }
1255
1256 // --- Hilfsgroessen ----------------------------------------------------------
1257
1258 /**
1259 * Furtlaenge einer Fussgaengergruppe: der laengste Raeumweg der
1260 * Konfliktbeziehungen, in denen die Gruppe RAEUMT (fromId = Gruppe).
1261 *
1262 * Der Raeumweg einer querenden Fussgaengergruppe ist der Weg vom Bordstein bis
1263 * zum Ende des Konfliktbereichs mit dem jeweiligen Fahrzeugstrom. Quert die
1264 * Furt mehrere Stroeme, ist der laengste dieser Wege die beste verfuegbare
1265 * Angabe zur Furtlaenge - eine eigene Furtlaenge fuehrt das Modell nicht.
1266 * Ohne solche Beziehung ist die Laenge unbekannt (`null`); geraten wird nicht.
1267 */
1268 function furtlaengeDerGruppe(project: Project, group: SignalGroup): number | null {
1269 let laenge = 0;
1270 for (const conflict of project.conflicts) {
1271 if (conflict.fromId !== group.id) continue;
1272 if (isFiniteNumber(conflict.clearingDistance) && conflict.clearingDistance > laenge) {
1273 laenge = conflict.clearingDistance;
1274 }
1275 }
1276 return laenge > 0 ? laenge : null;
1277 }
1278
1279 /**
1280 * Die massgebende Mindestfreigabezeit einer Signalgruppe.
1281 *
1282 * DIE EINE STELLE, aus der Freigabezeitverteilung (phaseMinGreen) UND
1283 * Pruefung (resolveGroupTimes -> mindestfreigabezeit-unterschritten) ihre
1284 * Untergrenze beziehen. Zwei Rechnungen nebeneinander liefen beim naechsten
1285 * Feld auseinander - und dann verteilte der Plan mit einer anderen Untergrenze,
1286 * als die Pruefung anlegt.
1287 *
1288 * Rechenweg (Fassung 5.4.0, Befund C1):
1289 * wert = max(Regelwert der Verkehrsart, Vorgabe an der Gruppe, Furtbedarf)
1290 * Furtbedarf (nur Fussgaenger mit erfasster Furtlaenge):
1291 * Furtweg / vr, Furtweg = Furtlaenge / 2, mit Zusatzeinrichtung fuer Blinde
1292 * und Sehbehinderte die ganze Furtlaenge; vr = REGELWERT der
1293 * Raeumgeschwindigkeit fuer die Gruppe (1,2 m/s, bei erhoehtem Zeitbedarf
1294 * 1,0 m/s) - aufgerundet auf ganze Sekunden.
1295 *
1296 * vr ist der Regelwert der Gruppe, NICHT zwingend die Geschwindigkeit der
1297 * Zwischenzeitrechnung: Dort kann an jeder Konfliktbeziehung eine
1298 * Ueberschreibung stehen (`clearingSpeedOverride`), die hier unberuecksichtigt
1299 * bleibt - die Furtregel fragt nach dem Fussgaenger auf der Furt, nicht nach
1300 * einer einzelnen Beziehung (der Kommentar behauptete vor der Fassung 5.4.0
1301 * "dieselbe Geschwindigkeit wie beim Raeumen").
1302 *
1303 * Beispiel: Furt 14 m, vr 1,2 m/s: halbe Furt 7 m -> 5,833 s -> 6 s (statt
1304 * 5 s Regelwert); mit Blindenzusatz 14 m -> 11,667 s -> 12 s.
1305 */
1306 export function mindestfreigabezeitDerGruppe(
1307 project: Project,
1308 group: SignalGroup,
1309 defaults: RilsaDefaults,
1310 ): Mindestfreigabezeit {
1311 const regelwert = minGreenTime(group.mode, defaults);
1312 const override = group.minGreenOverride;
1313 const vorgabe = override !== null && isFiniteNumber(override) ? override : null;
1314
1315 let furt: FurtFreigabebedarf | null = null;
1316 if (group.mode === 'fuss') {
1317 const furtlaenge = furtlaengeDerGruppe(project, group);
1318 if (furtlaenge !== null) {
1319 // Regelwert der Raeumgeschwindigkeit fuer diese Gruppe (Verkehrsart,
1320 // erhoehter Zeitbedarf) aus den Vorgaben. Ueberschreibungen an einzelnen
1321 // Konfliktbeziehungen (`clearingSpeedOverride`) bleiben unberuecksichtigt
1322 // - siehe Kopfkommentar.
1323 const vr = clearingSpeedFor(
1324 { mode: 'fuss', movement: group.movement, reducedMobility: group.reducedMobility },
1325 defaults,
1326 );
1327 furt = furtFreigabebedarf(furtlaenge, vr, group.blindenzusatz === true);
1328 }
1329 }
1330
1331 // Ganze Sekunden, aufgerundet - die Rundungsregel steht EINMAL in
1332 // furtFreigabebedarf (signalTimes.ts), nicht hier.
1333 const furtWert = furt !== null ? furt.zeitbedarfGerundet : 0;
1334
1335 let wert = regelwert;
1336 let massgebend: Mindestfreigabezeit['massgebend'] = 'regelwert';
1337 // Eine kuerzere als die Mindestfreigabezeit wird nicht uebernommen.
1338 if (vorgabe !== null && vorgabe > wert) {
1339 wert = vorgabe;
1340 massgebend = 'vorgabe';
1341 }
1342 if (furtWert > wert) {
1343 wert = furtWert;
1344 massgebend = 'furt';
1345 }
1346 return { wert, regelwert, vorgabe, furt, massgebend };
1347 }
1348
1349 /**
1350 * Hinweise zum Furtbedarf einer Fussgaengergruppe - nachrechenbar, mit allen
1351 * Groessen im Satz.
1352 *
1353 * Zwei Faelle: Der Furtbedarf uebersteigt den Regelwert (dann steht der
1354 * Rechenweg da, auch wenn am Ende eine hoehere Vorgabe gewinnt); oder die
1355 * Furtlaenge ist unbekannt, weil die Gruppe in keiner Konfliktbeziehung mit
1356 * Raeumweg raeumt - dann kann die Furtregel nicht geprueft werden, und das
1357 * darf nicht wie "geprueft und in Ordnung" aussehen.
1358 */
1359 function furtNotizen(group: SignalGroup, mfz: Mindestfreigabezeit): PlanNote[] {
1360 if (group.mode !== 'fuss') return [];
1361
1362 if (mfz.furt === null) {
1363 return [
1364 {
1365 severity: 'hinweis',
1366 code: 'mindestfreigabezeit-ohne-furtlaenge',
1367 signalGroupId: group.id,
1368 message:
1369 `Für die Fußgängergruppe "${group.name}" ist keine Konfliktbeziehung mit Räumweg erfasst, ` +
1370 'in der sie räumt; die Furtlänge ist damit unbekannt. Die Furtregel der RiLSA (Freigabe ' +
1371 `mindestens für die halbe Furt) kann nicht geprüft werden; es gilt allein der Regelwert von ` +
1372 `${formatSeconds(mfz.regelwert)} s.`,
1373 },
1374 ];
1375 }
1376
1377 const furt = mfz.furt;
1378 const furtWert = furt.zeitbedarfGerundet;
1379 if (furtWert <= mfz.regelwert) return [];
1380
1381 const anteil = furt.ganzeFurt
1382 ? 'die ganze Furt (Zusatzeinrichtung für Blinde und Sehbehinderte, DIN 32981)'
1383 : 'die halbe Furt';
1384 // Eine Vorgabe gewinnt nur, wenn sie den Furtbedarf nicht unterschreitet -
1385 // bei Gleichheit (Vorgabe 6 s, Furt 6 s) bleibt sie massgebend, "liegt aber
1386 // nicht darueber"; der Satz muss beide Faelle tragen.
1387 const ergebnis =
1388 mfz.massgebend === 'furt'
1389 ? `Die Mindestfreigabezeit beträgt deshalb ${formatSeconds(mfz.wert)} s statt des Regelwerts von ` +
1390 `${formatSeconds(mfz.regelwert)} s.`
1391 : mfz.wert > furtWert
1392 ? `Maßgebend bleibt die Vorgabe von ${formatSeconds(mfz.wert)} s, die darüber liegt.`
1393 : `Maßgebend bleibt die Vorgabe von ${formatSeconds(mfz.wert)} s, die den Furtbedarf genau deckt.`;
1394 // Furtweg mit zwei, Zeitbedarf mit drei Nachkommastellen: Bei einer Furt
1395 // von 12,01 m stuende sonst "6,01 m / 1,2 m/s = 5,00 s, aufgerundet 6 s" -
1396 // ein Rechenweg, der sein Ergebnis nicht hergibt. Mit 5,004 s ist die
1397 // Aufrundung nachvollziehbar.
1398 return [
1399 {
1400 severity: 'hinweis',
1401 code: 'mindestfreigabezeit-aus-furtlaenge',
1402 signalGroupId: group.id,
1403 message:
1404 `Die Fußgängergruppe "${group.name}" quert eine Furt von ${formatMeters(furt.furtlaenge)} m ` +
1405 '(längster erfasster Räumweg). Nach RiLSA muss die Freigabezeit für ' +
1406 `${anteil} reichen: ${formatMetersFine(furt.furtweg)} m / ${formatSpeed(furt.vr)} m/s = ` +
1407 `${formatSecondsFine(furt.zeitbedarf)} s, aufgerundet ${formatSeconds(furtWert)} s. ${ergebnis}`,
1408 },
1409 ];
1410 }
1411
1412 /**
1413 * Warnung, wenn die an der Gruppe eingetragene Mindestfreigabezeit UNTER dem
1414 * massgebenden Wert liegt und deshalb keine Wirkung hat.
1415 *
1416 * KORREKTUR (Fassung 5.4.0, Befund C9): mindestfreigabezeitDerGruppe
1417 * hebt eine zu kurze Vorgabe still auf den Regelwert (bzw. den Furtbedarf) -
1418 * fachlich richtig, aber der eingetragene Wert blieb im Feld stehen, und
1419 * nirgends stand, dass mit einem anderen gerechnet wird. Der Bearbeiter las
1420 * "3 s" und der Plan rechnete mit 5 s. Jetzt sagt der Bericht beides: was
1421 * eingetragen ist und was gilt. Eine Warnung, kein Fehler - das Ergebnis ist
1422 * richtig, nur die Eingabe ist irrefuehrend.
1423 *
1424 * Gemeldet wird jede Vorgabe unter dem MASSGEBENDEN Wert, nicht nur unter dem
1425 * Regelwert: Eine Vorgabe von 5,5 s an einer Furt mit 6 s Bedarf ist genauso
1426 * wirkungslos wie 3 s - und die Furtnotiz nennt die Vorgabe nicht.
1427 */
1428 function vorgabeNotizen(group: SignalGroup, mfz: Mindestfreigabezeit): PlanNote[] {
1429 if (mfz.vorgabe === null || mfz.vorgabe >= mfz.wert) return [];
1430
1431 const untergrenze =
1432 mfz.vorgabe < mfz.regelwert
1433 ? `den Regelwert der Verkehrsart von ${formatSeconds(mfz.regelwert)} s`
1434 : `den Furtbedarf von ${formatSeconds(mfz.wert)} s (siehe den Hinweis zur Mindestfreigabezeit)`;
1435 return [
1436 {
1437 severity: 'warnung',
1438 code: 'mindestfreigabezeit-vorgabe-unterschritten',
1439 signalGroupId: group.id,
1440 message:
1441 `Für die Signalgruppe "${group.name}" ist eine Mindestfreigabezeit von ` +
1442 `${formatSeconds(mfz.vorgabe)} s eingetragen; sie unterschreitet ${untergrenze}. ` +
1443 `Maßgebend bleibt ${formatSeconds(mfz.wert)} s; die Vorgabe hat keine Wirkung.`,
1444 },
1445 ];
1446 }
1447
1448 function resolveGroupTimes(
1449 group: SignalGroup,
1450 mindestfreigabezeit: Mindestfreigabezeit,
1451 defaults: RilsaDefaults,
1452 ): SignalGroupTimes {
1453 const base = signalGroupTimes(group.mode, group.vZul, defaults);
1454 return { ...base, minGreen: mindestfreigabezeit.wert };
1455 }
1456
1457 /**
1458 * Hat die Verkehrsart eine Saettigungsverkehrsstaerke im Sinne des HBS?
1459 * Fussgaenger und Radverkehr werden an Lichtsignalanlagen nicht ueber
1460 * Kapazitaet und Auslastungsgrad bewertet (HBS 2015; Fassung 5.4.0,
1461 * Befund B4); ihre Verkehrsstaerken gehen deshalb weder in Kapazitaet noch in
1462 * die Umlaufzeitermittlung ein.
1463 */
1464 export function hatSaettigungsverkehrsstaerke(mode: SignalGroup['mode']): boolean {
1465 return mode === 'kfz' || mode === 'oepnv';
1466 }
1467
1468 /**
1469 * Die erfasste Verkehrsstaerke eines Stroms - oder `undefined`, wenn keine
1470 * erfasst ist.
1471 *
1472 * DAS EINE KRITERIUM (Fassung 5.10.0): Ein DATENSATZ ist noch keine Zaehlung.
1473 * `actions.setDemand` und `actions.setLastzugAnteil` legen einen Datensatz mit
1474 * `volume: 0` an, sobald in der Signalgruppentabelle nur die Spalte "SV-Anteil"
1475 * oder "Lastzuege am SV" ausgefuellt wird. Bis dahin entschieden
1476 * `phaseFlowRatio` und die Kapazitaetsrechnung allein am Vorhandensein des
1477 * Datensatzes: Die Phase kam mit dem Saettigungsgrad 0 statt als Luecke
1478 * zurueck, und `computeCapacity` lieferte einen gesetzten Auslastungsgrad 0,00.
1479 * Damit fielen beide Meldungen weg, die die fehlende Zahl benennen
1480 * ('saettigungsgrade-unvollstaendig' und
1481 * 'leistungsfaehigkeit.keine-verkehrsstaerken') - waehrend Y zu klein blieb und
1482 * die Umlaufzeit zu kurz ausfiel, also in die gefaehrliche Richtung.
1483 *
1484 * `volume <= 0` heisst deshalb hier dasselbe wie in der Pruefung der
1485 * uebersaettigten Stroeme (validation/rules.ts), in der Simulation
1486 * (simulation/simulation.ts) und in der Fuehrung (ui/naechsterSchritt.ts):
1487 * keine Angabe. "Gezaehlt und 0 Fz/h" laesst sich davon nicht unterscheiden -
1488 * beide Bedienwege erzeugen denselben Datensatz -, und eine massgebende
1489 * Kfz-Verkehrsstaerke von 0 ist kein Bemessungsfall; nach der Zaehlung zu
1490 * fragen ist die sichere Richtung. Ein unbrauchbarer Wert (nicht endlich,
1491 * negativ) faellt in dieselbe Behandlung und wird ueber die beiden Meldungen
1492 * sichtbar, nicht stumm gerechnet.
1493 *
1494 * SO WEIT UND NICHT WEITER: Diese Lesart gilt fuer die Leser dieser Funktion -
1495 * Planaufbau, Umlaufzeit und Leistungsfaehigkeit -, nicht im ganzen Programm.
1496 * Die Anordnungskriterien der RSA 21 (validation/rules.ts, einstreifigBefunde)
1497 * zaehlen weiter jeden Datensatz mit und schreiben danach "zusammen 0 Kfz/h"
1498 * statt "nicht erfasst"; das liegt ausserhalb dieser Behebung und ist gemeldet.
1499 *
1500 * DIE AUSGABEN SIND NACHGEZOGEN (Fassung 5.10.0): Hier stand daneben,
1501 * Planunterlage und Tabellenausgabe gaeben die Spalte Verkehrsstaerke
1502 * unveraendert als "0 Fz/h" aus. Das gilt fuer keine von beiden mehr - der
1503 * Ausdruck setzt an allen drei Stellen einen Gedankenstrich
1504 * (services/export/pdf.ts), die Tabellenausgabe laesst die Zelle leer
1505 * (services/export/csv.ts).
1506 */
1507 function erfassteVerkehrsstaerke(demand: TrafficDemand | undefined): number | undefined {
1508 if (demand === undefined) return undefined;
1509 if (!isFiniteNumber(demand.volume) || demand.volume <= 0) return undefined;
1510 return demand.volume;
1511 }
1512
1513 /*
1514 * Schwerverkehrsanteil, mit dem gerechnet wird: der eingetragene Anteil,
1515 * begrenzt auf 0 bis 1 - `wirksamerSchwerverkehrsanteil` aus rilsa/capacity.ts.
1516 *
1517 * KORREKTUR (Fassung 5.4.0): Hier stand `Math.min(0.9, ...)`. Das Schema laesst
1518 * bis 1,0 zu, die Ansicht zeigte 95 %, gerechnet wurde mit 90 % - ohne Meldung.
1519 * Fuer die Kappung bei 0,9 gab es keinen fachlichen Grund: Ein Strom aus lauter
1520 * Bussen hat 100 % Schwerverkehr, und base / (1 + 1) ist eine ordentliche Zahl.
1521 * Jetzt gilt der Bereich des Schemas, 0 bis 1; die Ansicht laesst 0 bis 100 %
1522 * zu. Was darueber hinausgeht (nur an der Datei vorbei erreichbar), wird
1523 * begrenzt und von schwerverkehrNotizen gemeldet - kein stiller Ersatz.
1524 *
1525 * ZWEITE KORREKTUR (Fassung 5.6.0): Die Funktion stand hier als eigene Fassung
1526 * derselben Grenze, waehrend der Pruefbericht eine dritte fuehrte - naemlich
1527 * gar keine. Sie steht jetzt neben der Gleichung, die sie braucht.
1528 */
1529
1530 /**
1531 * Warnung je Nachfrage, deren Schwerverkehrsangaben nicht verwendbar sind.
1532 *
1533 * Zwei Faelle, zwei Folgen: Ein Schwerverkehrsanteil ausserhalb von 0 bis 1
1534 * wird BEGRENZT und mit dem begrenzten Wert gerechnet (siehe
1535 * wirksamerSchwerverkehrsanteil); ein unbrauchbarer Lastzuganteil wird
1536 * VERWORFEN und der Strom nach Gl. 2-6 gerechnet (siehe istLastzuganteil in
1537 * rilsa/capacity.ts). Beides steht im Bericht - kein stiller Ersatz.
1538 */
1539 function schwerverkehrNotizen(project: Project): PlanNote[] {
1540 const notizen: PlanNote[] = [];
1541 for (const demand of project.demands) {
1542 const group = findSignalGroup(project, demand.signalGroupId);
1543 if (!group || !hatSaettigungsverkehrsstaerke(group.mode)) continue;
1544 /*
1545 * Ein eingetragener, aber unbrauchbarer Lastzuganteil. Er faellt auf
1546 * Gl. 2-6 zurueck - dieselbe stille Ersetzung, die beim
1547 * Schwerverkehrsanteil beanstandet und behoben worden ist, nur eine Zeile
1548 * weiter: Der Bearbeiter hat eine Aufteilung eingetragen, gerechnet wird
1549 * ohne sie, und ohne diese Notiz stuende davon nichts im Bericht. Der
1550 * Pruefbericht meldet dann zwar den Pauschalwert, aber mit dem Satz "keine
1551 * Aufteilung erfasst" - und das waere aus Sicht des Bearbeiters schlicht
1552 * falsch.
1553 *
1554 * Ueber die Datei erreichbar ist der Fall nicht (schema.ts verwirft solche
1555 * Eintraege bereits beim Lesen und meldet sie); ueber die Oberflaeche und
1556 * ueber Fremdwerkzeuge, die ein Projekt zusammenbauen, sehr wohl.
1557 *
1558 * DASS DIE OBERFLAECHE IHN ERREICHT, IST EINE ENTSCHEIDUNG UND KEIN ZUFALL:
1559 * Das Feld "Lastzuege am SV" reicht einen Wert ausserhalb von 0 bis 100 %
1560 * unbegrenzt weiter (signalGroupsView.ts, `ausserhalbUebernehmen`). Waere
1561 * er dort geklemmt, wuerde aus -30 % ein gezaehltes "kein einziger
1562 * Lastzug", diese Notiz entstuende nie, und sie waere ausser von den Tests
1563 * von niemandem mehr erreichbar.
1564 */
1565 if (demand.lastzugAnteil !== undefined && !istLastzuganteil(demand.lastzugAnteil)) {
1566 notizen.push({
1567 severity: 'warnung',
1568 code: 'lastzuganteil-ausserhalb',
1569 signalGroupId: group.id,
1570 message:
1571 `Für die Signalgruppe "${group.name}" ist ein Lastzuganteil am Schwerverkehr von ` +
1572 `${isFiniteNumber(demand.lastzugAnteil) ? `${formatSeconds(demand.lastzugAnteil * 100)} %` : 'kein gültiger Zahlenwert'} ` +
1573 'eingetragen; zulässig sind 0 bis 100 %. Die Aufteilung des Schwerverkehrs gilt damit ' +
1574 'als nicht erfasst; gerechnet wird mit dem Pauschalwert des HBS 2015 (1,90 je ' +
1575 'Schwerfahrzeug).',
1576 });
1577 }
1578
1579 const wirksam = wirksamerSchwerverkehrsanteil(demand.heavyVehicleShare);
1580 if (wirksam === demand.heavyVehicleShare) continue;
1581 const eingetragen = isFiniteNumber(demand.heavyVehicleShare)
1582 ? `${formatSeconds(demand.heavyVehicleShare * 100)} %`
1583 : 'kein gültiger Zahlenwert';
1584 notizen.push({
1585 severity: 'warnung',
1586 code: 'schwerverkehrsanteil-ausserhalb',
1587 signalGroupId: group.id,
1588 message:
1589 `Für die Signalgruppe "${group.name}" ist ein Schwerverkehrsanteil von ${eingetragen} ` +
1590 `eingetragen; zulässig sind 0 bis 100 %. Gerechnet wird mit ${formatSeconds(wirksam * 100)} %.`,
1591 });
1592 }
1593 return notizen;
1594 }
1595
1596 /**
1597 * Saettigungsverkehrsstaerke einer Signalgruppe einschliesslich Schwerverkehr.
1598 *
1599 * KORREKTUR (Fassung 5.5.0): Hier stand `base / (1 + share)` mit dem Kommentar,
1600 * ein Pkw-Aequivalent von 2,0 sei "der uebliche Ansatz". Es war der einzige
1601 * Kennwert, der nicht in constants.ts stand - und er ist nachweislich der Wert
1602 * des HCM 2010, waehrend das Programm ihn unter der HBS-Kapazitaetsquelle
1603 * fuehrte. Gerechnet wird jetzt mit dem Anpassungsfaktor fSV des HBS 2015
1604 * (rilsa/capacity.ts, schwerverkehrsfaktor; Pkw-Gleichwerte in constants.ts,
1605 * HBS_PKW_GLEICHWERTE).
1606 *
1607 * WELCHE DER BEIDEN GLEICHUNGEN GILT, ENTSCHEIDET DIE DATENLAGE DIESES STROMS
1608 * (Schema 13): Ist an der Verkehrsstaerke ein Lastzuganteil erfasst
1609 * (`TrafficDemand.lastzugAnteil`), rechnet dieser Strom nach Gl. 2-5 mit den
1610 * Pkw-Gleichwerten 1,75 und 2,50; sonst nach Gl. 2-6 mit dem Pauschalwert 1,90.
1611 * Genau diese Fallunterscheidung sieht das HBS 2015 vor - "in Abhaengigkeit von
1612 * der Datenverfuegbarkeit zur Aufteilung des Schwerverkehrs".
1613 *
1614 * Beide Wege koennen im selben Plan nebeneinander stehen, und das ist kein
1615 * Widerspruch, sondern die Wahrheit ueber die Eingabe: An der einen Zufahrt
1616 * wurde gezaehlt, an der anderen nicht. Der Pruefbericht sagt fuer jeden Strom,
1617 * welcher Weg gilt.
1618 */
1619 /**
1620 * Die erfasste Geometrie einer Signalgruppe fuer die Kapazitaetsrechnung.
1621 *
1622 * Die Umsetzung steht hier und nicht in `rilsa/capacity.ts`: Der Fachkern
1623 * kennt das Projektmodell nicht, und er soll es nicht kennen. Ansicht und
1624 * Ausdruck nehmen dieselbe Stelle, damit angezeigte, gedruckte und gerechnete
1625 * Geometrie nicht auseinanderlaufen koennen.
1626 */
1627 export function fahrstreifengeometrie(group: SignalGroup): Fahrstreifengeometrie {
1628 return {
1629 breite: group.fahrstreifenbreite,
1630 radius: group.kurvenradius,
1631 neigung: group.laengsneigung,
1632 };
1633 }
1634
1635 function groupSaturationFlow(
1636 group: SignalGroup,
1637 demand: TrafficDemand | undefined,
1638 defaults: RilsaDefaults,
1639 ): number {
1640 if (!hatSaettigungsverkehrsstaerke(group.mode)) return Number.POSITIVE_INFINITY;
1641 const base = saturationFlow(group.movement, group.lanes, defaults, fahrstreifengeometrie(group));
1642 const share = wirksamerSchwerverkehrsanteil(demand?.heavyVehicleShare ?? 0);
1643 // Der begrenzte Anteil geht auch in die Aufteilung ein - sonst rechnete
1644 // Gl. 2-5 mit einem pSV, das Gl. 2-6 an derselben Stelle bereits verworfen
1645 // hat, und zwei Stroeme mit demselben Eintrag kaemen auf verschiedene Wege.
1646 return (
1647 base / schwerverkehrsfaktor(share, aufteilungAusLastzuganteil(share, demand?.lastzugAnteil))
1648 );
1649 }
1650
1651 /**
1652 * Massgebender Saettigungsgrad einer Phase - oder `undefined`, wenn er sich
1653 * nicht bilden laesst, weil keine Verkehrsstaerke erfasst ist.
1654 *
1655 * KORREKTUR (Fassung 5.9.0): Hier stand `let max = 0` und ein
1656 * `continue` fuer jede Gruppe ohne Verkehrsstaerke. Die Phase kam damit mit dem
1657 * Saettigungsgrad 0 aus der Rechnung - genau wie eine Phase, deren Verkehr
1658 * gezaehlt und zu 0 befunden wurde. Die Umlaufzeitermittlung konnte die Luecke
1659 * gar nicht sehen; ihre Meldung griff nur bei einer LEEREN Liste. Die
1660 * ermittelte Umlaufzeit fiel dadurch zu kurz aus.
1661 *
1662 * Unterschieden wird nach der Datenlage, nicht nach dem Ergebnis:
1663 * - Die Phase fuehrt Signalgruppen mit Saettigungsverkehrsstaerke, zu keiner
1664 * davon ist eine Verkehrsstaerke eingetragen -> `undefined` (Luecke).
1665 * - Die Phase fuehrt ueberhaupt keine solche Signalgruppe (reine
1666 * Fussgaengerphase) -> 0. Das ist der Regelfall und keine Luecke.
1667 *
1668 * WAS "EINGETRAGEN" HEISST, entscheidet `erfassteVerkehrsstaerke`
1669 * (Fassung 5.10.0): das Vorhandensein des Datensatzes genuegte
1670 * nicht - ein allein ueber den Schwerverkehrsanteil entstandener Datensatz
1671 * traegt `volume: 0` und schaltete diese Luecke ab.
1672 *
1673 * Das Gewicht der Phase in `distributeGreenTimes` bleibt `?? 0`: Fuer die
1674 * Verteilung ist eine nicht erfasste Phase so gut wie eine ohne Verkehr.
1675 */
1676 function phaseFlowRatio(
1677 project: Project,
1678 phase: Phase,
1679 defaults: RilsaDefaults,
1680 ): Ratio | undefined {
1681 let max = 0;
1682 let mitSaettigungsverkehrsstaerke = false;
1683 let erfasst = false;
1684 for (const id of phase.signalGroupIds) {
1685 const group = findSignalGroup(project, id);
1686 if (!group || !hatSaettigungsverkehrsstaerke(group.mode)) continue;
1687 mitSaettigungsverkehrsstaerke = true;
1688 const demand = demandFor(project, id);
1689 const volume = erfassteVerkehrsstaerke(demand);
1690 if (volume === undefined) continue;
1691 erfasst = true;
1692 const saturation = groupSaturationFlow(group, demand, defaults);
1693 const ratio = criticalFlowRatio(volume, saturation);
1694 if (Number.isFinite(ratio)) max = Math.max(max, ratio);
1695 }
1696 if (mitSaettigungsverkehrsstaerke && !erfasst) return undefined;
1697 return max;
1698 }
1699
1700 /**
1701 * Mindestfreigabezeit einer Phase: die groesste ihrer Signalgruppen - aus
1702 * derselben Rechnung wie die Pruefung je Gruppe (Fassung 5.4.0,
1703 * Befund C1). Bis dahin stand hier eine zweite Fassung von
1704 * max(Regelwert, Vorgabe) ohne die Furtregel; die Verteilung haette eine Furt
1705 * mit 5 s bedient, die die Pruefung danach mit "mindestens 6 s" beanstandet.
1706 */
1707 function phaseMinGreen(project: Project, phase: Phase, defaults: RilsaDefaults): Seconds {
1708 let max = 0;
1709 for (const id of phase.signalGroupIds) {
1710 const group = findSignalGroup(project, id);
1711 if (!group) continue;
1712 max = Math.max(max, mindestfreigabezeitDerGruppe(project, group, defaults).wert);
1713 }
1714 return max;
1715 }
1716
1717 /**
1718 * Die feste Freigabezeit einer Phase, sonst null.
1719 *
1720 * Eine von Hand gesetzte Freigabezeit gilt nur, wenn sie die
1721 * Mindestfreigabezeit der Phase einhaelt; darunter setzt die Verteilung die
1722 * Mindestfreigabezeit an und meldet das als 'phase-freigabezeit-zu-kurz'.
1723 * Genau diese Bedingung entscheidet zugleich darueber, ob die
1724 * Hoechstfreigabezeit der Signalgruppen zur Wirkung kommt - deshalb steht sie
1725 * hier einmal und wird an beiden Stellen gefragt.
1726 */
1727 function festeFreigabezeit(phase: Phase, minGreen: Seconds): Seconds | null {
1728 const manual = phase.manualGreen;
1729 if (manual === null || !isFiniteNumber(manual) || manual < minGreen) return null;
1730 return manual;
1731 }
1732
1733 /**
1734 * Hoechstfreigabezeit einer Phase: die KLEINSTE Vorgabe ihrer Signalgruppen,
1735 * sonst der Regelwert aus den Projektvorgaben.
1736 *
1737 * DIE VORGABE JE SIGNALGRUPPE WIRKT (Fassung 5.10.0): Hier stand fuer jede
1738 * Phase unbedingt `defaults.maxGreen`. `SignalGroup.maxGreenOverride` wurde
1739 * eingelesen, aus 4.x uebernommen ("damit bewusst gesetzte Werte nicht verloren
1740 * gehen", migrate.ts) und beim Speichern zurueckgeschrieben - gelesen hat es
1741 * niemand. Die Behandlung war damit genau verkehrt herum: Ein UNLESBARER
1742 * Eintrag wurde beim Einlesen gemeldet ("Gerechnet wird ohne diese obere
1743 * Schranke - pruefen Sie die Signalgruppe.", schema.ts), ein LESBARER stumm
1744 * verworfen. Diese Meldung setzt voraus, dass ein lesbarer Wert als obere
1745 * Schranke wirkt.
1746 *
1747 * WARUM DER KLEINSTE: Alle Signalgruppen einer Phase erhalten dieselbe
1748 * Freigabezeit. Eine Obergrenze ist nur dann bei allen eingehalten, wenn die
1749 * kleinste eingehalten ist. Ein Wert oberhalb des Regelwerts bindet nicht -
1750 * dann haelt schon der Regelwert ihn ein.
1751 *
1752 * WARUM DIE MINDESTFREIGABEZEIT VORGEHT: Sie ist die harte Schranke der RiLSA;
1753 * eine Obergrenze darunter waere nicht schaltbar. Die Verteilung setzt in
1754 * diesem Fall ohnehin die Untergrenze an - hier wird die Vorgabe zusaetzlich
1755 * als wirkungslos gemeldet, statt still uebergangen zu werden. Dieselbe
1756 * Behandlung wie bei der Mindestfreigabezeit unter dem Regelwert
1757 * ('mindestfreigabezeit-vorgabe-unterschritten', vorgabeNotizen).
1758 *
1759 * Ein unbrauchbarer Eintrag (nicht endlich) gilt als nicht gesetzt; das
1760 * Einlesen hat ihn dann bereits gemeldet. Ein negativer laeuft in dieselbe
1761 * Meldung wie eine zu kleine Vorgabe.
1762 *
1763 * ANGESETZT WIRD DIESE SCHRANKE NICHT, wo die Phase eine feste Freigabezeit
1764 * traegt: Dort setzt der Aufrufer minGreen = maxGreen = Vorgabe. Die
1765 * unmittelbare Angabe zur Phase geht der abgeleiteten Schranke ihrer
1766 * Signalgruppen vor - so wie sie auch der Verteilung nach Verkehrsstaerken
1767 * vorgeht. GEMELDET wird sie dort trotzdem, mit eigenem Satz
1768 * ('hoechstfreigabezeit-unter-fester-freigabezeit'): Vor der Fassung 5.10.0
1769 * entstand hier fuer jede Phase dieselbe Meldung wie ohne Handvorgabe - sie
1770 * behauptete "Massgebend bleibt die Mindestfreigabezeit", waehrend die feste
1771 * Freigabezeit galt, und lief mit dieser falschen Aussage ueber den
1772 * Pruefbericht in die gedruckte Planunterlage. Lag die Vorgabe zwischen
1773 * Mindest- und fester Freigabezeit, verfiel sie umgekehrt ganz stumm
1774 * - genau der Zustand, den die Befunde 36 und 42 beanstanden. Massstab ist
1775 * deshalb der Wert, der ohne die Vorgabe der Signalgruppen gaelte: die feste
1776 * Freigabezeit, sonst der Regelwert.
1777 *
1778 * AUCH DER PROJEKTVORGABEWERT WIRD ANGEHOBEN UND GEMELDET (Fassung 5.13.0). Bis
1779 * dahin kehrte die Funktion bei `massgebend === null` mit dem unveraenderten
1780 * Regelwert zurueck. Lag DIESER unter der Mindestfreigabezeit der Phase,
1781 * geschah zweierlei:
1782 *
1783 * - Der Anwender erfuhr nichts. Fuer denselben Sachverhalt - eine
1784 * Hoechstfreigabezeit unter der Mindestfreigabezeit - gab es eine Meldung,
1785 * wenn die Zahl an einer Signalgruppe stand, und keine, wenn sie in der
1786 * Vorgabenverwaltung stand.
1787 * - Die Verteilung bekam eine Phase mit maxGreen < minGreen. An den
1788 * Freigabezeiten aendert das nichts - die Mindestfreigabezeit geht ihnen
1789 * vor -, wohl aber an der Auskunft daneben, und die geht ueber den
1790 * Pruefbericht in die gedruckte Planunterlage. Gemessen an der
1791 * Standardvorlage mit minGreenKfz 40 s und Hoechstfreigabezeit 10 s: tU
1792 * 100 s und 40/40 s vorher wie nachher, davor jedoch mit der Warnung
1793 * 'freigabezeit-rest' - "Alle Phasen erreichen ihre Höchstfreigabezeit;
1794 * 30 s der Umlaufzeit bleiben übrig ... von 10 s auf 40 s" - bei
1795 * Freigabezeiten von 40 s gegen eine angebliche Obergrenze von 10 s.
1796 * Dieselbe Vorlage mit einer dritten, reinen Fussgaengerphase und fester
1797 * Umlaufzeit von 120 s fuehrt vorher wie nachher 60/40/10 s; die
1798 * Restmeldung nannte dort davor "50 s ... von 10 s auf 60 s", waehrend es
1799 * 20 s und "von 40 s auf 60 s" sind. Die Annahme, der BERICHT sei in
1800 * beiden Faellen derselbe, traegt also nicht.
1801 *
1802 * NACHTRAG (10.09.2026, Fassung 5.23.0): Beide Zahlenreihen dieses
1803 * Absatzes gelten dem Stand bis 5.22.0 und sind hier stehengeblieben, weil
1804 * sie den damaligen Befund tragen. Heute sind es 40/40/30 s, und die
1805 * Restmeldung nennt keine Verlaengerung "von ... auf ..." mehr, sondern die
1806 * Phasen, die den Rest bekommen. Der Rest geht seither nicht mehr ganz an
1807 * eine Phase (greenSplit.ts).
1808 *
1809 * Angehoben wird deshalb hier, gleich der Behandlung der Signalgruppenvorgabe.
1810 * GEMELDET wird dagegen nicht hier: Der Regelwert ist EINE Zahl, und eine
1811 * Meldung je Phase saehe im Bericht wie mehrere Befunde aus. Die Funktion
1812 * vermerkt den Fall nur; den einen Satz dazu setzt
1813 * `vorgabeHoechstfreigabezeitNotiz`.
1814 */
1815 interface Hoechstfreigabezeit {
1816 /** Obere Schranke, mit der die Phase in die Freigabezeitverteilung geht. */
1817 readonly wert: Seconds;
1818 /**
1819 * Der Regelwert aus den Projektvorgaben liegt unter der Mindestfreigabezeit
1820 * dieser Phase und bleibt deshalb ohne Wirkung.
1821 */
1822 readonly vorgabeOhneWirkung: boolean;
1823 }
1824
1825 function phaseMaxGreen(
1826 project: Project,
1827 phase: Phase,
1828 defaults: RilsaDefaults,
1829 minGreen: Seconds,
1830 fest: Seconds | null,
1831 notes: PlanNote[],
1832 ): Hoechstfreigabezeit {
1833 const ohneVorgabe = fest ?? defaults.maxGreen;
1834 let wert = ohneVorgabe;
1835 let massgebend: SignalGroup | null = null;
1836 for (const id of phase.signalGroupIds) {
1837 const group = findSignalGroup(project, id);
1838 if (!group) continue;
1839 const vorgabe = group.maxGreenOverride;
1840 if (vorgabe === null || !isFiniteNumber(vorgabe) || vorgabe >= wert) continue;
1841 wert = vorgabe;
1842 massgebend = group;
1843 }
1844
1845 /*
1846 * ALLE, DIE DENSELBEN WERT TRAGEN, und nicht nur die erste (Fassung 5.24.0).
1847 * Der Vergleich oben uebernimmt nur einen echt kleineren Wert; tragen zwei
1848 * Signalgruppen derselben Phase dieselbe Zahl, blieb die zuerst zugeordnete
1849 * stehen - und allein ihr Name ging in die Warnung. Wer die Zuordnung in der
1850 * Phasenansicht neu setzte, bekam dieselbe Warnung ueber die andere Gruppe,
1851 * bei unveraendertem Plan. Beanstandet gehoert jede Vorgabe, die ohne
1852 * Wirkung bleibt.
1853 *
1854 * Die Sprungmarke bleibt bei einer Gruppe: Der Bericht springt an eine
1855 * Stelle, nicht an drei. Genannt werden alle.
1856 */
1857 const gleichauf: SignalGroup[] = [];
1858 if (massgebend !== null) {
1859 for (const id of phase.signalGroupIds) {
1860 const group = findSignalGroup(project, id);
1861 if (group && isFiniteNumber(group.maxGreenOverride) && group.maxGreenOverride === wert) {
1862 gleichauf.push(group);
1863 }
1864 }
1865 }
1866 // Nach Namen und nicht in der Zuordnungsreihenfolge: Sonst stuende dieselbe
1867 // Warnung nach einem Neuzuordnen der Gruppen mit vertauschten Namen da.
1868 const betroffene =
1869 gleichauf.length > 1
1870 ? `Signalgruppen ${[...gleichauf]
1871 .sort((a, b) => a.name.localeCompare(b.name, 'de'))
1872 .map((g) => `"${g.name}"`)
1873 .join(' und ')} ist`
1874 : `Signalgruppe "${massgebend?.name ?? ''}" ist`;
1875
1876 if (massgebend === null) {
1877 // Bei fester Freigabezeit ist der Regelwert nicht die Schranke der Phase;
1878 // dort gilt die Handvorgabe, und die haelt die Mindestfreigabezeit schon
1879 // nach festeFreigabezeit ein.
1880 if (fest === null && ohneVorgabe < minGreen) {
1881 return { wert: minGreen, vorgabeOhneWirkung: true };
1882 }
1883 return { wert: ohneVorgabe, vorgabeOhneWirkung: false };
1884 }
1885
1886 if (fest !== null) {
1887 notes.push({
1888 severity: 'warnung',
1889 code: 'hoechstfreigabezeit-unter-fester-freigabezeit',
1890 signalGroupId: massgebend.id,
1891 message:
1892 `Für die ${betroffene} eine Höchstfreigabezeit von ` +
1893 `${formatSeconds(wert)} s eingetragen; für "${phase.name}" ist eine feste Freigabezeit ` +
1894 `von ${formatSeconds(fest)} s vorgegeben. Maßgebend bleibt die feste Freigabezeit der ` +
1895 'Phase; die Vorgabe hat keine Wirkung.',
1896 });
1897 return { wert: fest, vorgabeOhneWirkung: false };
1898 }
1899
1900 if (wert < minGreen) {
1901 notes.push({
1902 severity: 'warnung',
1903 code: 'hoechstfreigabezeit-unter-mindestfreigabezeit',
1904 signalGroupId: massgebend.id,
1905 message:
1906 `Für die ${betroffene} eine Höchstfreigabezeit von ` +
1907 `${formatSeconds(wert)} s eingetragen; "${phase.name}" braucht mindestens ` +
1908 `${formatSeconds(minGreen)} s. Maßgebend bleibt die Mindestfreigabezeit; die Vorgabe ` +
1909 'hat keine Wirkung.',
1910 });
1911 return { wert: minGreen, vorgabeOhneWirkung: false };
1912 }
1913 return { wert, vorgabeOhneWirkung: false };
1914 }
1915
1916 /**
1917 * Der EINE Satz zu einer Hoechstfreigabezeit aus den Projektvorgaben, die
1918 * unter der Mindestfreigabezeit einer Phase liegt.
1919 *
1920 * WARUM EINMAL UND NICHT JE PHASE: Der Regelwert ist eine einzige Zahl aus der
1921 * Vorgabenverwaltung. Liegt er unter den Mindestfreigabezeiten von acht
1922 * Phasen, ist das eine irrefuehrende Eingabe und nicht acht - ein Bericht, der
1923 * achtmal denselben Satz fuehrt, verdeckt die uebrigen Befunde. Die
1924 * betroffenen Phasen stehen deshalb mit ihrer Mindestfreigabezeit im Satz.
1925 *
1926 * WARUM KEINE EIGENE KENNUNG: Der Sachverhalt ist derselbe, den die
1927 * Signalgruppenvorgabe unter der Mindestfreigabezeit ausloest - Ueberschrift
1928 * ("Höchstfreigabezeit unter der Mindestfreigabezeit") und Einstufung des
1929 * Pruefberichts passen woertlich. Eine neue Kennung fiele in der Engine auf
1930 * die Rueckfalltexte zurueck ("Hinweis aus dem Planaufbau"), und das waere
1931 * fuer den Leser schlechter. Der Satz nennt die Vorgabenverwaltung selbst,
1932 * damit niemand an einer Signalgruppe nach einem Eintrag sucht, den es dort
1933 * nicht gibt.
1934 *
1935 * WARUM WARNUNG UND NICHT FEHLER: Das Ergebnis ist richtig - die
1936 * Mindestfreigabezeit geht vor. Irrefuehrend ist allein die Eingabe. Dieselbe
1937 * Einstufung wie bei 'mindestfreigabezeit-vorgabe-unterschritten'.
1938 */
1939 function vorgabeHoechstfreigabezeitNotiz(
1940 phases: readonly Phase[],
1941 phaseMinGreens: readonly Seconds[],
1942 hoechstfreigabezeiten: readonly Hoechstfreigabezeit[],
1943 defaults: RilsaDefaults,
1944 ): PlanNote[] {
1945 const betroffen = phases
1946 .map((phase, index) => ({ phase, minGreen: phaseMinGreens[index] ?? 0, index }))
1947 .filter((eintrag) => hoechstfreigabezeiten[eintrag.index]?.vorgabeOhneWirkung === true);
1948 if (betroffen.length === 0) return [];
1949
1950 const liste = betroffen
1951 .map((eintrag) => `"${eintrag.phase.name}" (${formatSeconds(eintrag.minGreen)} s)`)
1952 .join(', ');
1953 const mehrere = betroffen.length > 1;
1954 return [
1955 {
1956 severity: 'warnung',
1957 /*
1958 * EIGENER KODE, nicht der der Signalgruppenvorgabe. Beide Faelle sind
1959 * derselbe Sachverhalt, aber der naechste Schritt ist ein anderer: Dort
1960 * ist an einer Signalgruppe etwas eingetragen, das zu entfernen oder
1961 * anzuheben ist; hier steht die Zahl in der Vorgabenverwaltung, und an
1962 * keiner Signalgruppe ist etwas zu finden. Der Rat zu
1963 * 'hoechstfreigabezeit-unter-mindestfreigabezeit' schickte den Bearbeiter
1964 * an eine Signalgruppe, an der nichts steht - `suggestionFor`
1965 * (domain/validation/engine.ts) kennt nur den Kode, nicht die Meldung,
1966 * und kann die beiden Faelle deshalb nicht auseinanderhalten.
1967 */
1968 code: 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit',
1969 message:
1970 `In den Projektvorgaben steht eine Höchstfreigabezeit von ` +
1971 `${formatSeconds(defaults.maxGreen)} s. ` +
1972 `${mehrere ? 'Diese Phasen brauchen' : 'Diese Phase braucht'} mehr: ${liste}. ` +
1973 `Maßgebend bleibt dort die Mindestfreigabezeit; die Vorgabe hat ` +
1974 `${mehrere ? 'für diese Phasen' : 'für diese Phase'} keine Wirkung.`,
1975 },
1976 ];
1977 }
1978
1979 /**
1980 * Umlaufzeit ermitteln oder uebernehmen.
1981 *
1982 * Die Umlaufzeitermittlung in rilsa/cycle.ts kennt die Anlagenart nicht - sie
1983 * rastert, begrenzt und beurteilt den Regelbereich ausschliesslich anhand von
1984 * `defaults.cycleTime`. Ihr wird deshalb ein Kennwertsatz uebergeben, dessen
1985 * Umlaufzeitbereich der der Anlagenart ist. Die uebrigen Kennwerte bleiben
1986 * unveraendert, und `plan.defaults` behaelt den unverfaelschten Satz aus der
1987 * Vorgabenverwaltung.
1988 */
1989 function resolveCycleTime(
1990 project: Project,
1991 input: Parameters<typeof computeCycleTime>[1],
1992 defaults: RilsaDefaults,
1993 grenzen: AnlagenartGrenzen,
1994 notes: PlanNote[],
1995 ): {
1996 cycleTime: Seconds;
1997 cycleResult: CycleTimeResult | null;
1998 cycleComparison: CycleTimeComparison;
1999 } {
2000 const cycleDefaults: RilsaDefaults = {
2001 ...defaults,
2002 cycleTime: { ...defaults.cycleTime, ...grenzen.cycleTime },
2003 };
2004
2005 /*
2006 * DER VERGLEICH WIRD IMMER GERECHNET - auch bei fester Vorgabe. Gerade dort
2007 * ist er die Auskunft, die fehlte: Wer eine Umlaufzeit von Hand setzt, sieht
2008 * jetzt, was die vier Ansaetze aus denselben Eingaben machen, und kann seine
2009 * Wahl begruenden.
2010 *
2011 * ER BRINGT KEINE EIGENEN MELDUNGEN IN DEN PLAN. `notes` bekommt weiterhin
2012 * nur die Meldungen des GEWAEHLTEN Verfahrens; die drei anderen rechnen an
2013 * einer Umlaufzeit, die niemand schaltet, und ihre Beanstandungen gehoeren
2014 * nicht in den Pruefbericht dieser Planung. Sie stehen im Vergleich und
2015 * werden dort neben ihrem Verfahren ausgewiesen.
2016 *
2017 * Dieselben Eingaben, dieselben Schranken: `input` und `cycleDefaults` sind
2018 * die, mit denen auch das gewaehlte Verfahren rechnet. Das gewaehlte
2019 * Ergebnis wird unten AUS dem Vergleich genommen und nicht ein zweites Mal
2020 * gerechnet - sonst koennten Tabelle und Plan auseinanderlaufen.
2021 */
2022 const cycleComparison = compareCycleTimeMethods(input, cycleDefaults);
2023
2024 if (project.program.method === 'manuell') {
2025 const manual = project.program.manualCycleTime;
2026 if (manual === null || !isFiniteNumber(manual) || manual <= 0) {
2027 notes.push({
2028 severity: 'fehler',
2029 code: 'umlaufzeit-fehlt',
2030 message:
2031 'Es ist eine feste Umlaufzeit gewählt, aber kein Wert eingetragen. ' +
2032 `Angesetzt wird die Mindestumlaufzeit von ${formatSeconds(input.minimumCycle ?? grenzen.cycleTime.min)} s.`,
2033 });
2034 return {
2035 cycleTime: input.minimumCycle ?? grenzen.cycleTime.min,
2036 cycleResult: null,
2037 cycleComparison,
2038 };
2039 }
2040 const minimum = input.minimumCycle ?? 0;
2041 if (manual < minimum) {
2042 notes.push({
2043 severity: 'fehler',
2044 code: 'umlaufzeit-zu-kurz',
2045 message:
2046 `Die vorgegebene Umlaufzeit von ${formatSeconds(manual)} s reicht nicht aus. ` +
2047 `Aus Mindestfreigabezeiten und Zwischenzeiten ergeben sich mindestens ` +
2048 `${formatSeconds(minimum)} s.`,
2049 });
2050 return { cycleTime: minimum, cycleResult: null, cycleComparison };
2051 }
2052 /*
2053 * KORREKTUR (Fassung 5.4.0, Befund C4): Der berechnete Zweig
2054 * meldet eine Umlaufzeit ausserhalb des Regelbereichs (cycle.ts), der
2055 * feste Wert lief ohne diesen Hinweis durch - 120 s von Hand am
2056 * Knotenpunkt blieben stumm, waehrend dieselben 120 s aus der Rechnung
2057 * gemeldet wurden. Dieselbe Meldung mit demselben Wortlaut, gegen dieselben
2058 * Schranken der Anlagenart. Ein Hinweis, keine Schranke: Der Regelbereich
2059 * ist eine Empfehlung, die harten Grenzen prueft der Bericht getrennt.
2060 */
2061 const { recommendedMin, recommendedMax } = grenzen.cycleTime;
2062 if (manual < recommendedMin || manual > recommendedMax) {
2063 notes.push({
2064 severity: 'hinweis',
2065 code: 'umlaufzeit-ausserhalb-regelbereich',
2066 message:
2067 `Die Umlaufzeit ${formatSeconds(manual)} s liegt außerhalb des üblichen Bereichs von ` +
2068 `${formatSeconds(recommendedMin)} bis ${formatSeconds(recommendedMax)} s.`,
2069 });
2070 }
2071 return { cycleTime: manual, cycleResult: null, cycleComparison };
2072 }
2073
2074 const result = cycleComparison.results[project.program.method];
2075 notes.push(...result.notes);
2076 return { cycleTime: result.cycleTime, cycleResult: result, cycleComparison };
2077 }
2078
2079 function emptyPlan(
2080 project: Project,
2081 defaults: RilsaDefaults,
2082 grenzen: AnlagenartGrenzen,
2083 intergreens: ReadonlyMap<string, ResolvedIntergreen>,
2084 notes: PlanNote[],
2085 ): SignalPlan {
2086 return {
2087 cycleTime: 0,
2088 cycleResult: null,
2089 cycleComparison: null,
2090 anlagenart: project.anlagenart,
2091 grenzen,
2092 phases: [],
2093 transitions: [],
2094 groups: [],
2095 intergreens,
2096 transitionTime: 0,
2097 lostTime: 0,
2098 feasible: false,
2099 notes,
2100 defaults,
2101 };
2102 }
2103
2104 function modulo(value: number, m: number): number {
2105 if (m <= 0) return 0;
2106 return ((value % m) + m) % m;
2107 }
2108
2109 function formatSeconds(value: number): string {
2110 return roundTo(value, 1).toString().replace('.', ',');
2111 }
2112
2113 /**
2114 * Drei Nachkommastellen fuer einen ungerundeten Zeitbedarf (5,833 s). Drei
2115 * und nicht zwei: Der Furtweg steht mit zwei Nachkommastellen daneben, und
2116 * 6,01 m / 1,2 m/s = 5,004 s muss als "mehr als 5 s" lesbar sein, sonst
2117 * ergibt der gedruckte Rechenweg nicht die aufgerundeten 6 s.
2118 */
2119 function formatSecondsFine(value: number): string {
2120 return roundTo(value, 3).toFixed(3).replace('.', ',');
2121 }
2122
2123 function formatMeters(value: number): string {
2124 return roundTo(value, 2).toString().replace('.', ',');
2125 }
2126
2127 /** Zwei feste Nachkommastellen fuer den Furtweg (7,00 m; 6,01 m). */
2128 function formatMetersFine(value: number): string {
2129 return roundTo(value, 2).toFixed(2).replace('.', ',');
2130 }
2131
2132 function formatSpeed(value: number): string {
2133 return roundTo(value, 2).toString().replace('.', ',');
2134 }
2135
2136 /** Konfliktbeziehungen als Nachschlagewerk fuer die Oberflaeche. */
2137 export function conflictLookup(project: Project): ReadonlyMap<string, Conflict> {
2138 return new Map(project.conflicts.map((c) => [intergreenKey(c.fromId, c.toId), c]));
2139 }