import type { Meters, Ratio, Seconds } from '../units'; import { clamp, isFiniteNumber, roundTo } from '../units'; import { ANLAGENART_GRENZEN, aufteilungAusLastzuganteil, clearingSpeedFor, computeCapacity, computeDelay, computeFussRadDelay, sperrzeitenAusFenstern, compareCycleTimeMethods, computeEngstellenIntergreen, computeIntergreen, criticalFlowRatio, distributeGreenTimes, computeCycleTime, furtFreigabebedarf, istLastzuganteil, minGreenTime, resolveRilsaSettings, saturationFlow, schwerverkehrsfaktor, signalGroupTimes, wirksamerSchwerverkehrsanteil, type AnlagenartGrenzen, type CalculationNote, type CapacityResult, type CycleTimeComparison, type Fahrstreifengeometrie, type CycleTimeResult, type FurtFreigabebedarf, type IntergreenResult, type RilsaDefaults, type SignalGroupTimes, } from '../rilsa'; import { rueckstaubewertung, type DelayResult, type Rueckstaubewertung } from '../rilsa/delay'; import type { ClearingStream, EnteringStream, Movement, UeberfahrzeitAnsatz } from '../rilsa/types'; import { demandFor, findSignalGroup, orderedPhases, type Anlagenart, type Conflict, type Phase, type Project, type SignalGroup, type TrafficDemand, } from '../model/project'; /** * Aufbau des vollstaendigen Signalzeitenplans aus den Projektdaten. * * Diese Datei ist die einzige Stelle, an der aus Phasen, Zwischenzeiten und * Umlaufzeit ein schaltbarer Plan entsteht. Darstellung, Simulation, Pruefung * und Ausdruck arbeiten alle mit demselben Ergebnis. * * Im Altbestand rechnete jede dieser vier Stellen eigenstaendig: die Anzeige * summierte Phasendauern, die Simulation nahm feste 30 s je Phase, der Ausdruck * las die gespeicherte Umlaufzeit und die Pruefung wieder etwas anderes. * Derselbe Knotenpunkt wurde dadurch je nach Ansicht unterschiedlich * dargestellt. */ /** Ein Freigabezeitfenster einer Signalgruppe innerhalb des Umlaufs. */ export interface GreenInterval { /** Beginn der Freigabezeit, bezogen auf den Umlaufbeginn (0 .. tU). */ readonly start: Seconds; /** Dauer der Freigabezeit. */ readonly duration: Seconds; } /** * Meldung aus dem Planaufbau. * * Erweitert die Rechenmeldung um die Signalgruppe, von der sie handelt - damit * der Pruefbericht auf die Gruppe zeigen kann statt pauschal auf das Programm * (dasselbe, was `konfliktId` fuer die Zwischenzeitmeldungen leistet). Alle * Rechenmeldungen bleiben ohne Aenderung gueltige Planmeldungen. */ export interface PlanNote extends CalculationNote { /** Signalgruppe, von der die Meldung handelt - nur gesetzt, wo es eine gibt. */ readonly signalGroupId?: string; } /** * Die Mindestfreigabezeit einer Signalgruppe mit ihrem Rechenweg. * * WARUM NICHT NUR EINE ZAHL: Fuer eine Fussgaengergruppe setzt sich der Wert * aus dem Regelwert (5 s), einer etwaigen Vorgabe und der Furtregel der RiLSA * zusammen. Der Ausdruck und der Bericht muessen sagen koennen, welcher der * drei gewonnen hat und wie er zustande kam - sonst steht dort "6 s" neben * einem Regelwert von 5 s, und niemand weiss, woher die Sekunde stammt. */ export interface Mindestfreigabezeit { /** * Massgebende Mindestfreigabezeit in GANZEN Sekunden - der Wert, mit dem * Freigabezeitverteilung und Pruefung rechnen. Der Furtbedarf wird dafuer * aufgerundet, nie abgerundet: Signalzeiten werden in ganzen Sekunden * geschaltet, und 5,83 s Bedarf sind mit 5 s nicht gedeckt. */ readonly wert: Seconds; /** Regelwert der Verkehrsart aus den Vorgaben. */ readonly regelwert: Seconds; /** Vorgabe an der Gruppe (`minGreenOverride`), sofern eine besteht. */ readonly vorgabe: Seconds | null; /** * Furtbedarf nach RiLSA - nur bei Fussgaengergruppen mit erfasster * Furtlaenge (laengster Raeumweg der Konfliktbeziehungen, in denen die * Gruppe raeumt). Ohne solche Beziehung `null`: Dann ist die Furtlaenge * unbekannt, und die Furtregel kann nicht geprueft werden. */ readonly furt: FurtFreigabebedarf | null; /** Woher der massgebende Wert stammt. */ readonly massgebend: 'regelwert' | 'vorgabe' | 'furt'; } export interface PlannedSignalGroup { readonly groupId: string; readonly name: string; readonly mode: SignalGroup['mode']; readonly color: string; readonly times: SignalGroupTimes; /** Rechenweg zur Mindestfreigabezeit in `times.minGreen`. */ readonly mindestfreigabezeit: Mindestfreigabezeit; /** Freigabezeitfenster; mehrere, wenn die Gruppe in getrennten Phasen freigegeben ist. */ readonly greens: readonly GreenInterval[]; /** Summe aller Freigabezeiten im Umlauf. */ readonly totalGreen: Seconds; readonly capacity: CapacityResult | null; readonly delay: DelayResult | null; /** Massgebender Saettigungsgrad y = q / qS. */ readonly flowRatio: Ratio | null; /** * Der mittlere Rueckstau als Laenge, gehalten gegen den erfassten Stauraum. * * `null`, wo die Bemessung keinen Rueckstau liefert - ohne Verkehrsstaerke, * ohne Umlaufzeit oder im HCM-Verfahren, das NGE nicht kennt. */ readonly rueckstau: Rueckstaubewertung | null; } export interface PlannedPhase { readonly id: string; readonly name: string; readonly start: Seconds; readonly duration: Seconds; readonly signalGroupIds: readonly string[]; /** Massgebender Saettigungsgrad der Phase. */ readonly flowRatio: Ratio; } export interface PhaseTransition { readonly fromPhaseId: string; readonly toPhaseId: string; /** Beginn des Uebergangs, bezogen auf den Umlaufbeginn. */ readonly start: Seconds; /** Dauer des Uebergangs: die groesste erforderliche Zwischenzeit. */ readonly duration: Seconds; /** Massgebende Beziehung, die die Dauer bestimmt. */ readonly critical: { readonly fromGroupId: string; readonly fromGroupName: string; readonly toGroupId: string; readonly toGroupName: string; readonly intergreen: Seconds; } | null; } export interface SignalPlan { readonly cycleTime: Seconds; /** Ergebnis der Umlaufzeitermittlung; null bei fester Vorgabe. */ readonly cycleResult: CycleTimeResult | null; /** * Alle vier Rechenansaetze nebeneinander, aus denselben Eingaben. * * Immer gefuellt, wenn der Plan Phasen hat - auch bei fester Vorgabe, wo er * die einzige Auskunft darueber ist, ob der Handwert zu den Verkehrsstaerken * passt. `null` nur am leeren Plan. * * DIE MELDUNGEN DER NICHT GEWAEHLTEN VERFAHREN STEHEN NUR HIER und nicht in * `notes`: Sie beziehen sich auf eine Umlaufzeit, die niemand schaltet. */ readonly cycleComparison: CycleTimeComparison | null; /** Art der Anlage, aus der die Schranken dieses Plans stammen. */ readonly anlagenart: Anlagenart; /** * Die fuer DIESEN Plan geltenden Schranken - Umlaufzeitbereich, groesster * Raeumweg, Wartezeitschwellen. * * Sie stehen getrennt von `defaults`, weil sie etwas anderes sind: * `defaults` sind die Kennwerte, mit denen gerechnet wird (Gelbzeiten, * Geschwindigkeiten, Saettigungsverkehrsstaerke) und die die * Vorgabenverwaltung anzeigt; `grenzen` ist der Rahmen, in dem das Ergebnis * liegen muss. Beides in ein Feld zu legen hiesse, die Vorgabentabelle im * Ausdruck wuerde bei einer Arbeitsstelle "Regelwert der groessten * Umlaufzeit: 300 s" behaupten - eine Zahl, die in der RiLSA nicht steht. */ readonly grenzen: AnlagenartGrenzen; readonly phases: readonly PlannedPhase[]; readonly transitions: readonly PhaseTransition[]; readonly groups: readonly PlannedSignalGroup[]; /** Zwischenzeiten je Konfliktbeziehung, Schluessel "fromId|toId". */ readonly intergreens: ReadonlyMap; /** Summe aller Uebergangszeiten. */ readonly transitionTime: Seconds; /** Verlustzeit je Umlauf: Uebergangszeiten zuzueglich Anfahrverluste. */ readonly lostTime: Seconds; /** Ist der Plan in sich schaltbar? */ readonly feasible: boolean; readonly notes: readonly PlanNote[]; readonly defaults: RilsaDefaults; } /** Zwischenzeit einer Beziehung samt Herkunft. */ export interface ResolvedIntergreen { readonly conflictId: string; readonly fromId: string; readonly toId: string; readonly value: Seconds; /** Von Hand vorgegeben oder gerechnet? */ readonly source: 'berechnet' | 'vorgegeben'; readonly calculation: IntergreenResult | null; } /** * Schluessel eines gerichteten Kennungspaares. * * SIE HAT ZWEI AUFGABEN, und wer sie aendert, aendert beide. * * ERSTENS ist sie der Schluessel der Zwischenzeitenkarte dieses Plans * (`intergreens`) und der Karten, die daran haengen: `intergreenBetween` hier, * `checkIntergreenGaps` und der ungerichtete Paarschluessel in * validation/engine.ts, die Zwischenzeitenmatrix in Ansicht, Ausdruck und CSV. * Fallen zwei Paare auf denselben Schluessel, bekommt eine Beziehung die * Zwischenzeit der anderen. * * ZWEITENS ist sie das AUSWAHLMERKMAL der vermessenen Wege: `wegSchluessel` * (src/domain/geometrie/ableitung.ts, Fassung 5.11.0) bildet damit den * Schluessel eines Strompaares aus den beiden PLANLINIENKENNUNGEN, und die * Auswahl des Anwenders im Lageplan geht als Menge dieser Schluessel durch * `uebernimmWege`. Wer hier die Bildung aendert, entscheidet also mit, WELCHER * Weg uebernommen wird: Fallen zwei Strompaare auf denselben Schluessel, wird * ein nicht angehaktes Paar mitgeschrieben und ein angehaktes bleibt * ungeschrieben. Der Schluessel wird dort nie zerlegt, sondern nur verglichen - * er muss eindeutig sein, nicht umkehrbar. * * EINDEUTIG, AUCH BEI EINER KENNUNG MIT "|" (Fassung 5.10.0): Hier stand * `${fromId}|${toId}` ohne jede Absicherung. Eine Signalgruppenkennung kam * damals ungeprueft aus der Datei - das Einlesen beanstandete nur die leere und * die doppelt vergebene, kein Zeichenrepertoire -, und fuer die Kennungen * "a|b", "c", "a" und "b|c" ergab das zweimal denselben Schluessel "a|b|c". Die * Karte behielt den zuletzt geschriebenen Eintrag, die andere Beziehung erhielt * die FREMDE Zwischenzeit, und war die kuerzer, wurde der Phasenuebergang zu * kurz bemessen - die gefaehrliche Richtung. Eine Gegenprobe gab es nicht: * `checkIntergreenGaps` schlaegt unter demselben Schluessel nach. * * `maskiereKennung` macht das "|" in der Kennung unkenntlich; der verbindende * Strich ist danach der einzige im Schluessel, die Zuordnung damit umkehrbar. * Kennungen aus `createId` (a-z, 0-9, "-") bleiben unveraendert, der Schluessel * liest sich fuer sie weiterhin als "fromId|toId". * * SEIT DEM ZEICHENREPERTOIRE (`ZULAESSIGE_KENNUNG` in model/schema.ts, * Fassung 5.12.0) kann eine eingelesene Kennung weder "|" noch "~" * enthalten; die Maskierung laeuft fuer sie leer. Sie bleibt trotzdem stehen: * Sie kostet nichts, und sie haelt den Schluessel auch dann eindeutig, wenn das * Repertoire eines Tages ein weiteres Zeichen aufnimmt. */ export function intergreenKey(fromId: string, toId: string): string { return `${maskiereKennung(fromId)}|${maskiereKennung(toId)}`; } /** * Maskiert die Zeichen, die den Schluessel mehrdeutig machen: "~" wird zu * "~~", "|" zu "~s". Beides ist eindeutig umkehrbar, und im Ergebnis kommt * kein "|" mehr vor. */ function maskiereKennung(id: string): string { return id.includes('~') || id.includes('|') ? id.replaceAll('~', '~~').replaceAll('|', '~s') : id; } /** * Vermerk, den jede Ausgabe der Umlaufzeit neben den Wert setzt, wenn er kein * Bemessungsergebnis ist - EIN Wortlaut fuer Ansicht, Ausdruck und CSV. * * Anwendertext, deshalb mit Gedankenstrich - wie jeder andere Anzeigetext des * Programms; das PDF setzt ihn beim Drucken selbst in einen Bindestrich um * (toWinAnsi). Mit einem Bindestrich hier las sich die Kennzahl gemischt: * "120 s – nicht bemessbar (uebersaettigt) - Ersatzwert". */ export const UMLAUFZEIT_ERSATZWERT_VERMERK = 'nicht bemessbar (übersättigt) – Ersatzwert'; /** * Ist die Umlaufzeit dieses Plans nur ein Ersatzwert? * * KORREKTUR (Fassung 5.4.0, Befund C11): Bei Uebersaettigung liefert * die Umlaufzeitermittlung die Obergrenze als Ersatzwert (cycle.ts, bounded * 'uebersaettigt'), damit der Plan eine Zahl hat - die groesste zulaessige * Umlaufzeit, also die Schranke der Anlagenart oder eine engere eigene * Vorgabe (siehe anlagenartGrenzen). Bis dahin trug der Plan "120 s" wie ein * Ergebnis - und Kennzahl, Ausdruck und CSV druckten die Zahl nackt, waehrend * nur der Pruefbericht sagte, dass sie keine ist. Jede Stelle, die * plan.cycleTime ausgibt, hat hier zu fragen und den Wert ueber * umlaufzeitMitVermerk zu kennzeichnen. * * Bei fester Vorgabe (cycleResult null) antwortet die Funktion immer mit * "nein" - auch dann, wenn resolveCycleTime eine fehlende oder zu kurze * Handvorgabe durch die Mindestumlaufzeit ersetzt hat. Auch das ist ein * Ersatzwert, aber einer mit eigener Fehler-Notiz (umlaufzeit-fehlt, * umlaufzeit-zu-kurz) und Sperre; der Vermerk hier nennt die Uebersaettigung * beim Namen und passt auf jenen Fall nicht. */ export function umlaufzeitIstErsatzwert(plan: Pick): boolean { return plan.cycleResult?.bounded === 'uebersaettigt'; } /** * Haengt den Ersatzwert-Vermerk an eine bereits formatierte Umlaufzeit * ("120 s" -> "120 s – nicht bemessbar (uebersaettigt) – Ersatzwert"), wenn * der Plan nur einen Ersatzwert fuehrt; sonst kommt die Zahl unveraendert * zurueck. Die Zahl formatiert der Aufrufer (Anzeige, Ausdruck, Zeichnung * haben je ihre Formatierung), das Zusammenfuegen steht nur hier - vor der * Fassung 5.4.0 stand es dreimal gleichlautend in Statuszeile, Signalzeitenplan * und Ausdruck. */ export function umlaufzeitMitVermerk(plan: Pick, zahl: string): string { return umlaufzeitIstErsatzwert(plan) ? `${zahl} – ${UMLAUFZEIT_ERSATZWERT_VERMERK}` : zahl; } /** * Die fuer ein Projekt geltenden Schranken. * * WELCHE SCHRANKE GEWINNT - DIE ANLAGENART ODER DIE VORGABENVERWALTUNG? * * Die Anlagenart bestimmt, welcher Bereich fachlich ueberhaupt in Frage kommt. * Die Vorgabenverwaltung (rilsa/settings.ts) bestimmt, was der Anwender in * DIESEM Projekt zulassen will. Der Anwender darf den Bereich der Anlagenart * einengen, aber nicht aufweiten: Eine Baustellenampel wird nicht dadurch * vertretbar, dass jemand 400 s eintraegt, und ein Knotenpunkt wird es nicht * durch 200 s. Deshalb der engere der beiden Werte. * * Entscheidend ist die zweite Haelfte der Regel: Nur eine AUSDRUECKLICH * eingetragene Vorgabe zaehlt als Anwenderwille. `defaults.cycleTime` fuehrt * immer einen Wert - ohne Eintrag ist es RILSA_DEFAULTS, und das ist die * Knotenpunktzeile des Regelwerks (30 bis 120 s). Wuerde sie als Vorgabe * gelten, schnitte sie jede Arbeitsstelle wieder bei 120 s ab: genau der * Zustand, wegen dem eine einstreifige Verkehrsfuehrung bisher nicht rechenbar * war. Ob eingetragen wurde, steht nur in den Rohvorgaben des Projekts. * * Uebernommen wird dann aber der von settings.ts bereits gepruefte Wert aus * `defaults`, nicht die Roheingabe. Sonst rechnete der Plan mit einer Zahl, die * die Vorgabenpruefung im selben Pruefbericht als unzulaessig meldet. */ export function anlagenartGrenzen( project: Project, defaults: RilsaDefaults, notes: CalculationNote[] = [], ): AnlagenartGrenzen { const grenzen = ANLAGENART_GRENZEN[project.anlagenart]; const eingetragen = project.settings.rilsa; const min = eingetragen.cycleTimeMin !== undefined ? Math.max(grenzen.cycleTime.min, defaults.cycleTime.min) : grenzen.cycleTime.min; const max = eingetragen.cycleTimeMax !== undefined ? Math.min(grenzen.cycleTime.max, defaults.cycleTime.max) : grenzen.cycleTime.max; if (min > max) { // Kann mit den heutigen Zahlen nicht eintreten, waere aber der eine Fall, // in dem ein stiller Rueckfall gefaehrlich ist: Ein leerer Bereich liesse // jede Umlaufzeit sowohl "zu kurz" als auch "zu lang" sein. notes.push({ severity: 'fehler', code: 'umlaufzeit-grenzen-widerspruch', message: `Die vorgegebenen Umlaufzeitgrenzen ergeben zusammen mit den Schranken der Anlagenart ` + `einen leeren Bereich (${formatSeconds(min)} bis ${formatSeconds(max)} s). Es gelten die ` + `Schranken der Anlagenart, ${formatSeconds(grenzen.cycleTime.min)} bis ` + `${formatSeconds(grenzen.cycleTime.max)} s.`, }); return grenzen; } // Der Regelbereich ist ein Hinweis, keine Schranke. Ein ausdruecklich // eingetragener Wert gilt hier unveraendert; er wird lediglich in den // zulaessigen Bereich gelegt, damit die Meldung "ausserhalb des ueblichen // Bereichs" nicht auf einen Wert zeigt, der ohnehin nicht erreichbar ist. const recommendedMin = eingetragen.cycleTimeRecommendedMin !== undefined ? defaults.cycleTime.recommendedMin : grenzen.cycleTime.recommendedMin; const recommendedMax = eingetragen.cycleTimeRecommendedMax !== undefined ? defaults.cycleTime.recommendedMax : grenzen.cycleTime.recommendedMax; return { ...grenzen, cycleTime: { min, max, recommendedMin: clamp(recommendedMin, min, max), recommendedMax: clamp(recommendedMax, min, max), }, }; } /** Baut den Signalzeitenplan. Reine Funktion ohne Nebenwirkungen. */ export function buildSignalPlan(project: Project): SignalPlan { const notes: PlanNote[] = []; // Mit der Anlagenart des Projekts: Die Umlaufzeit-Vorgaben werden gegen // DEREN Schranken geprueft (120 s Knotenpunkt, 300 s einstreifig; Befund // C10). Ohne sie gaelte der Vorgabewert 'knotenpunkt' und kappte jeden // einstreifigen Plan mit 250 s auf 120 s. const resolved = resolveRilsaSettings(project.settings.rilsa, undefined, project.anlagenart); notes.push(...resolved.notes); const defaults = resolved.defaults; // Die Schranken stehen vor jeder Rechnung fest: An ihnen haengt die // Umlaufzeitermittlung, nicht nur die Beurteilung des Ergebnisses. const grenzen = anlagenartGrenzen(project, defaults, notes); // Vor der Rechnung mit den Verkehrsstaerken: Ein Schwerverkehrsanteil // ausserhalb von 0 bis 100 % wird begrenzt UND gemeldet. notes.push(...schwerverkehrNotizen(project)); const intergreens = resolveIntergreens(project, defaults, notes); const phases = orderedPhases(project); if (phases.length === 0) { notes.push({ severity: 'fehler', code: 'keine-phasen', message: 'Es ist keine Phase angelegt. Ohne Phasen lässt sich kein Signalzeitenplan aufbauen.', }); return emptyPlan(project, defaults, grenzen, intergreens, notes); } const transitions = computeTransitionRequirements(project, phases, intergreens); const transitionTime = transitions.reduce((sum, t) => sum + t.duration, 0); const lostTime = transitionTime + phases.length * defaults.startUpLostTime; const phaseFlowRatios = phases.map((phase) => phaseFlowRatio(project, phase, defaults)); const phaseMinGreens = phases.map((phase) => phaseMinGreen(project, phase, defaults)); const hoechstfreigabezeiten = phases.map((phase, index) => phaseMaxGreen( project, phase, defaults, phaseMinGreens[index] ?? 0, festeFreigabezeit(phase, phaseMinGreens[index] ?? 0), notes, ), ); const phaseMaxGreens = hoechstfreigabezeiten.map((h) => h.wert); notes.push( ...vorgabeHoechstfreigabezeitNotiz(phases, phaseMinGreens, hoechstfreigabezeiten, defaults), ); const minimumCycle = phaseMinGreens.reduce((a, b) => a + b, 0) + transitionTime; const { cycleTime, cycleResult, cycleComparison } = resolveCycleTime( project, { lostTime, criticalFlowRatios: phaseFlowRatios, minimumCycle, step: defaults.cycleTime.step }, defaults, grenzen, notes, ); const split = distributeGreenTimes( cycleTime, phases.map((phase, index) => { const manual = phase.manualGreen; const min = phaseMinGreens[index] ?? 0; // Eine von Hand gesetzte Freigabezeit wird als feste Vorgabe behandelt: // Unter- und Obergrenze fallen zusammen. Dieselbe Bedingung entscheidet // in festeFreigabezeit darueber, ob die Hoechstfreigabezeit der // Signalgruppen ueberhaupt zur Wirkung kommt - sie steht dort einmal. const fest = festeFreigabezeit(phase, min); if (fest !== null) { return { id: phase.id, weight: phaseFlowRatios[index] ?? 0, minGreen: fest, maxGreen: fest, }; } if (manual !== null && isFiniteNumber(manual) && manual < min) { notes.push({ severity: 'fehler', code: 'phase-freigabezeit-zu-kurz', message: `Für "${phase.name}" ist eine Freigabezeit von ${formatSeconds(manual)} s vorgegeben, ` + `die Mindestfreigabezeit beträgt jedoch ${formatSeconds(min)} s. Angesetzt wird die ` + 'Mindestfreigabezeit.', }); return { id: phase.id, weight: phaseFlowRatios[index] ?? 0, minGreen: min, maxGreen: min }; } return { id: phase.id, weight: phaseFlowRatios[index] ?? 0, minGreen: min, maxGreen: phaseMaxGreens[index] ?? defaults.maxGreen, }; }), transitions.map((t) => t.duration), ); notes.push(...split.notes); const durations = phases.map((phase) => split.phases.find((p) => p.id === phase.id)?.green ?? 0); // Startzeitpunkte: Phase, Uebergang, Phase, Uebergang ... const phaseStarts: Seconds[] = []; const transitionStarts: Seconds[] = []; let cursor = 0; for (let i = 0; i < phases.length; i += 1) { phaseStarts.push(cursor); cursor += durations[i] ?? 0; transitionStarts.push(cursor); cursor += transitions[i]?.duration ?? 0; } const actualCycle = cursor; const plannedPhases: PlannedPhase[] = phases.map((phase, index) => ({ id: phase.id, name: phase.name, start: roundTo(phaseStarts[index] ?? 0, 3), duration: roundTo(durations[index] ?? 0, 3), signalGroupIds: phase.signalGroupIds, flowRatio: roundTo(phaseFlowRatios[index] ?? 0, 4), })); const plannedTransitions: PhaseTransition[] = transitions.map((t, index) => ({ ...t, start: roundTo(transitionStarts[index] ?? 0, 3), })); const groups = buildGroupWindows( project, phases, phaseStarts, durations, transitions, transitionStarts, intergreens, actualCycle, defaults, notes, ); if (Math.abs(actualCycle - cycleTime) > 0.01) { notes.push({ severity: 'hinweis', code: 'umlaufzeit-angepasst', message: `Die Summe aus Freigabe- und Übergangszeiten ergibt ${formatSeconds(actualCycle)} s. ` + 'Dieser Wert ist maßgebend.', }); } return { cycleTime: roundTo(actualCycle, 3), cycleResult, cycleComparison, anlagenart: project.anlagenart, grenzen, phases: plannedPhases, transitions: plannedTransitions, groups, intergreens, transitionTime: roundTo(transitionTime, 3), lostTime: roundTo(lostTime, 3), feasible: split.feasible && actualCycle > 0, notes, defaults, }; } // --- Zwischenzeiten --------------------------------------------------------- function resolveIntergreens( project: Project, defaults: RilsaDefaults, notes: CalculationNote[], ): Map { const result = new Map(); /* * EINE Stelle entscheidet ueber das Verfahren, und beide Aufrufe nehmen sie: * die Rechnung selbst und die Gegenprobe zu einer von Hand vorgegebenen * Zwischenzeit. Zwei Aufbauten nebeneinander liefen beim naechsten Feld * auseinander - dieselbe Begruendung wie bei `raeumenderStrom`. * * An einer einstreifigen Verkehrsfuehrung gilt fuer den * KRAFTFAHRZEUGVERKEHR das eigene Verfahren des Abschnitts 5.2 * (Engstellensignalisierung); fuer Rad, Fussgaenger und OePNV bleibt es bei * Abschnitt 2, weil die RiLSA fuer sie in 5.2 nichts anderes sagt. Der * Radverkehr kommt dort vor - er wird mit 18 km/h massgebend, wenn die * Engstelle zu eng ist -, aber als Bedingung fuer den KFZ-Raeumvorgang; * das Programm kann die Enge nicht messen und meldet sie deshalb * (`einstreifig.radverkehr-massgebend` im Pruefbericht). */ const engstelle = project.anlagenart === 'einstreifig'; const zwischenzeit = ( from: SignalGroup, conflict: Conflict, to: SignalGroup, ): IntergreenResult => { const clearing = raeumenderStrom(from, conflict, project.settings.ueberfahrzeitAnsatz); const entering = einfahrenderStrom(to, conflict); if (engstelle && from.mode === 'kfz') { return computeEngstellenIntergreen(clearing, entering, defaults); } return computeIntergreen(clearing, entering, defaults); }; for (const conflict of project.conflicts) { const from = findSignalGroup(project, conflict.fromId); const to = findSignalGroup(project, conflict.toId); if (!from || !to) continue; if ( conflict.manualIntergreen !== null && isFiniteNumber(conflict.manualIntergreen) && conflict.manualIntergreen >= 0 ) { const vorgegeben = Math.ceil(conflict.manualIntergreen); /* * Ganze Sekunden - wie jede gerechnete Zwischenzeit (computeIntergreen * rundet auf). Vor der Fassung 5.4.0 geschah das Aufrunden stumm: 4,2 s * eingetragen, 5 s gerechnet, und nirgends stand es. Die Zahl im Feld * bleibt, die Notiz sagt, womit gerechnet wird. Ein Hinweis, kein * Fehler - aufgerundet ist die sichere Seite. */ if (vorgegeben !== conflict.manualIntergreen) { notes.push({ severity: 'hinweis', code: 'zwischenzeit-vorgabe-aufgerundet', konfliktId: conflict.id, message: `Zwischenzeit ${from.name} nach ${to.name}: Die von Hand vorgegebene Zwischenzeit ` + `${formatSeconds(conflict.manualIntergreen)} s wird auf ganze Sekunden aufgerundet; ` + `angesetzt werden ${vorgegeben} s.`, }); } /* * Gegenprobe zur Vorgabe. * * Die vorgegebene Zwischenzeit ersetzt die Rechnung vollstaendig - * computeIntergreen wird gar nicht erst gerufen. Damit liefen auch alle * Schranken ins Leere, die fuer die Einzelwerte gelten: die Obergrenze * der Raeumgeschwindigkeit, die Gelbzeit als kuerzeste Ueberfahrzeit. In * der Zwischenzeitenansicht stehen beide Felder nebeneinander; wer * kuerzen wollte, nahm einfach das Feld ohne Schranke. Die Vorgabe bleibt * zulaessig - der Planer kann einen abgestimmten Wert eintragen -, aber * sie darf den Rechenwert nicht mehr stillschweigend unterschreiten. * * Ohne Raeumweg gibt es keine Vergleichsgrundlage; dann wird nicht * verglichen. */ if (conflict.clearingDistance > 0) { const gegenprobe = zwischenzeit(from, conflict, to); if (vorgegeben < gegenprobe.intergreen) { notes.push({ severity: 'warnung', code: 'zwischenzeit-vorgabe-unter-rechenwert', konfliktId: conflict.id, message: `Zwischenzeit ${from.name} nach ${to.name}: Die vorgegebene Zwischenzeit ` + `${vorgegeben} s liegt unter den aus Räum- und Einfahrweg gerechneten ` + `${gegenprobe.intergreen} s. Die Abweichung ist zu begründen.`, }); } } result.set(intergreenKey(conflict.fromId, conflict.toId), { conflictId: conflict.id, fromId: conflict.fromId, toId: conflict.toId, value: vorgegeben, source: 'vorgegeben', calculation: null, }); continue; } /* * Eine negative oder unlesbare Handvorgabe ist keine Zwischenzeit. Vor der * Fassung 5.4.0 wurde sie stumm auf 0 s gehoben - und 0 s ERSETZTE die * Rechnung: die feindlichen Stroeme durften ohne Zwischenzeit aufeinander * folgen. Jetzt gilt sie als nicht gesetzt: Die Rechnung aus Raeum- und * Einfahrweg tritt an ihre Stelle, und der Bericht meldet einen Fehler * (Sperre), damit der Eintrag berichtigt wird. Das Schema (optionalNum) * faengt Unlesbares beim Einlesen ab; dies ist der zweite Riegel und der * einzige gegen ein negatives Vorzeichen. */ if (conflict.manualIntergreen !== null) { notes.push({ severity: 'fehler', code: 'zwischenzeit-vorgabe-ungueltig', konfliktId: conflict.id, message: `Zwischenzeit ${from.name} nach ${to.name}: Die von Hand vorgegebene Zwischenzeit ` + `${formatSeconds(conflict.manualIntergreen)} s ist kein zulässiger Wert (kleiner als 0 s ` + 'oder keine Zahl). Sie wird nicht übernommen; gerechnet wird aus Räum- und Einfahrweg. ' + 'Berichtigen oder entfernen Sie die Vorgabe an der Konfliktbeziehung.', }); } const calculation = zwischenzeit(from, conflict, to); for (const note of calculation.notes) { if (note.severity === 'hinweis') continue; notes.push({ ...note, konfliktId: conflict.id, message: `Zwischenzeit ${from.name} nach ${to.name}: ${note.message}`, }); } result.set(intergreenKey(conflict.fromId, conflict.toId), { conflictId: conflict.id, fromId: conflict.fromId, toId: conflict.toId, value: calculation.intergreen, source: 'berechnet', calculation, }); } return result; } /* * Die beiden Stroeme einer Konfliktbeziehung. * * Als eigene Funktionen, damit die Gegenprobe zu einer vorgegebenen * Zwischenzeit mit genau denselben Werten rechnet wie die Rechnung selbst. * Zwei Aufbauten nebeneinander liefen beim naechsten Feld auseinander. */ /** * DIE FAHRBEZIEHUNG DES STROMS, DEM DER RAEUMWEG EINER BEZIEHUNG GEHOERT - * nicht immer die der raeumenden Signalgruppe. * * Eine Signalgruppe kann mehrere Stroeme bedienen; geradeaus und rechts aus * einem Fahrstreifen ist der Regelfall. `uebernimmWege` waehlt dann je * Gruppenpaar das unguenstigste STROMpaar aus und legt dessen Geometrie in die * Beziehung - zusammen mit `raeumbeziehung`, wenn dieser Strom eine andere * Fahrbeziehung hat als seine Gruppe. Fehlt das Feld, gilt die Fahrbeziehung * der Gruppe: Eine Datei aelterer Fassung, ein von Hand erfasster Knotenpunkt * und ein Projekt aus 4.x sagen nichts darueber, welcher Strom den * gespeicherten Weg gefahren ist. * * Zuvor stand in `raeumenderStrom` schlicht `from.movement`. * Damit raeumte der Rechtsabbiegebogen mit der Geradeausgeschwindigkeit - * 10,0 statt 7,0 m/s. An `beispiele/vierarmiger-knotenpunkt.lsap` stand danach * an vier Beziehungen eine Zwischenzeit neben ihrem eigenen Widerspruch: * gedruckte 5 s neben dem Vermerk "(6 s)", nach erneuter Uebernahme aus dem * Lageplan 7 s neben "(9 s)". Zu kurz ist die gefaehrliche Richtung. * * ALS EIGENE, AUSGEFUEHRTE FUNKTION - und zwar aus demselben Grund, aus dem es * `engerRadiusWirktAn` gibt (intergreen.ts): Wer sonst noch wissen muss, mit * welcher Fahrbeziehung diese Beziehung raeumt - Pruefbericht, Konfliktfenster, * Planunterlage -, liest sie hier und baut die Entscheidung nicht ein zweites * Mal nach. Genau daraus ist der Fehler entstanden: Die Uebernahme aus dem * Lageplan entschied nach dem Strom, der Plan nach der Gruppe, und die * gedruckte Zeile widersprach sich selbst. */ export function massgebendeRaeumbeziehung( from: Pick, conflict: Pick, ): Movement { return conflict.raeumbeziehung ?? from.movement; } function raeumenderStrom( from: SignalGroup, conflict: Conflict, ueberfahrzeitAnsatz: UeberfahrzeitAnsatz, ): ClearingStream { return { mode: from.mode, movement: massgebendeRaeumbeziehung(from, conflict), clearingDistance: conflict.clearingDistance, vehicleClass: from.vehicleClass, vZul: from.vZul, ...(conflict.clearingSpeedOverride !== null ? { clearingSpeedOverride: conflict.clearingSpeedOverride } : {}), ...(conflict.crossingTimeOverride !== null ? { crossingTimeOverride: conflict.crossingTimeOverride } : {}), ...(from.reducedMobility ? { reducedMobility: true } : {}), // Halt vor dem Knotenpunkt (RiLSA-Fall 4) - wirkt nur bei OePNV-Stroemen. ...(conflict.haltVorKnoten === true ? { haltVorKnoten: true } : {}), // Enger Innenradius - wirkt nur bei abbiegenden Kfz-Stroemen. ...(conflict.engerRadius === true ? { engerRadius: true } : {}), /* * DIE EINZIGE STELLE, an der die Projektwahl des Ueberfahrzeit-Ansatzes in * die Rechnung eintritt. Sie steht hier und nicht in computeIntergreen, * damit die Gegenprobe zu einer von Hand gesetzten Zwischenzeit mit * demselben Ansatz rechnet wie die Rechnung selbst - der Grund, aus dem es * diese Funktion ueberhaupt gibt. */ ueberfahrzeitAnsatz, }; } function einfahrenderStrom(to: SignalGroup, conflict: Conflict): EnteringStream { return { mode: to.mode, movement: to.movement, enteringDistance: conflict.enteringDistance, ...(conflict.enteringSpeedOverride !== null ? { enteringSpeedOverride: conflict.enteringSpeedOverride } : {}), }; } /** Zwischenzeit einer Beziehung; 0, wenn kein Konflikt erfasst ist. */ export function intergreenBetween( intergreens: ReadonlyMap, fromId: string, toId: string, ): Seconds { return intergreens.get(intergreenKey(fromId, toId))?.value ?? 0; } // --- Phasenuebergaenge ------------------------------------------------------ /** * Erste Phase ab `von`, die ueberhaupt eine Signalgruppe fuehrt - vorwaerts * ueber die Umlaufgrenze hinweg. Fuehrt keine Phase eine Gruppe, kommt `von` * zurueck. */ function naechstePhaseMitGruppen(phases: readonly Phase[], von: number): number { const n = phases.length; for (let schritt = 0; schritt < n; schritt += 1) { const index = (von + schritt) % n; if (phases[index]!.signalGroupIds.length > 0) return index; } return von; } /** * Rang einer Beziehung im Wettstreit um die massgebende Zwischenzeit eines * Phasenuebergangs - vier Stufen, in dieser Reihenfolge verglichen. * * WARUM ES DIESEN RANG GIBT (Fassung 5.24.0). Verglichen wurde bis dahin * allein die aufgerundete Zwischenzeit, mit `>`: Bei Gleichstand blieb das * zuerst gepruefte Paar stehen, und geprueft wird in der Reihenfolge der * Signalgruppen der Folgephase. Am mitgelieferten Beispielknotenpunkt tritt * dieser Gleichstand wirklich auf, und die Wirkung ist sichtbar: Der Satz * " raeumt -> faehrt ein" steht in der Uebergangstabelle der * Planunterlage. Wer in der Phasenansicht eine Signalgruppe abwaehlte und * sofort wieder anwaehlte, ruecke sie ans Ende der Liste - und in der * gedruckten Unterlage stand danach ein anderes Paar, bei Byte fuer Byte * demselben Plan. * * DIE ERSTEN DREI STUFEN SIND FACHLICH. Die aufgerundete Zwischenzeit ist die * Zahl, die im Plan steht; der ungerundete Wert und der Raeumweg sagen, welche * der gleich aussehenden Beziehungen die anspruchsvollere ist - genau die * gehoert benannt, denn sie bleibt massgebend, wenn sich Gelbzeit oder * zulaessige Geschwindigkeit spaeter aendern. * * DIE VIERTE IST ES NICHT, und das steht hier, damit niemand sie dafuer haelt: * Sind zwei Beziehungen auch im ungerundeten Wert und im Raeumweg gleich, gibt * es nichts mehr, was sie unterscheidet. Dann entscheidet der Name - nicht * weil er etwas bedeutete, sondern weil er sich nicht verschiebt, wenn jemand * eine Signalgruppe neu zuordnet. Eine willkuerliche Wahl laesst sich hier * nicht vermeiden; eine WANDERNDE laesst sich vermeiden. */ interface Rangfolge { readonly zwischenzeit: Seconds; readonly roh: Seconds; readonly raeumweg: Meters; readonly name: string; } function rangDerBeziehung( intergreens: ReadonlyMap, fromId: string, toId: string, wert: Seconds, vonName: string, nachName: string, ): Rangfolge { const eintrag = intergreens.get(intergreenKey(fromId, toId)); return { zwischenzeit: wert, // Eine von Hand vorgegebene Zwischenzeit fuehrt keine Rechnung; dann ist // der vorgegebene Wert selbst das Genaueste, was vorliegt. roh: eintrag?.calculation?.raw ?? wert, raeumweg: eintrag?.calculation?.clearingPath ?? 0, name: `${vonName}${nachName}`, }; } /** Positiv, wenn `a` vor `b` liegt. */ function vergleicheRang(a: Rangfolge, b: Rangfolge): number { if (a.zwischenzeit !== b.zwischenzeit) return a.zwischenzeit - b.zwischenzeit; if (a.roh !== b.roh) return a.roh - b.roh; if (a.raeumweg !== b.raeumweg) return a.raeumweg - b.raeumweg; // Absteigend nach Name waere ebenso beliebig; entscheidend ist allein, dass // dieselbe Eingabe immer dieselbe Antwort gibt. return b.name.localeCompare(a.name, 'de'); } function computeTransitionRequirements( project: Project, phases: readonly Phase[], intergreens: ReadonlyMap, ): Omit[] { const result: Omit[] = []; for (let i = 0; i < phases.length; i += 1) { const from = phases[i]!; // Der Uebergang liegt im Umlauf zwischen dieser und der unmittelbar // folgenden Phase; daran haengen seine Lage und seine Beschriftung. const next = phases[(i + 1) % phases.length]!; /* * BEMESSEN wird dagegen gegen die naechste Phase, die ueberhaupt eine * Signalgruppe fuehrt (Befund F6). * * Eine Phase ohne Signalgruppe laesst nichts beginnen und nichts enden. * Gegen sie bemessen ergaben BEIDE angrenzenden Uebergaenge 0 s, und weil * eine solche Phase zugleich 0 s Freigabezeit erhaelt, begann die Freigabe * der Folgephase in derselben Sekunde, in der die der Vorphase endete: Die * gerechnete Zwischenzeit fiel ersatzlos weg. Das ist keine fachliche * Beurteilung, die dem Planaufbau abginge - dass eine Phase keine * Signalgruppe fuehrt, sieht er selbst. * * Die volle Zwischenzeit liegt damit im Uebergang, der die belegte Phase * VERLAESST. Der Uebergang aus der leeren Phase heraus bleibt 0 s, weil dort * nichts endet - doppelt gezaehlt wird nichts. Die Dauer der leeren Phase * wird nicht angerechnet; das liegt auf der sicheren Seite. */ const to = phases[naechstePhaseMitGruppen(phases, (i + 1) % phases.length)]!; const ending = from.signalGroupIds.filter((id) => !to.signalGroupIds.includes(id)); const starting = to.signalGroupIds.filter((id) => !from.signalGroupIds.includes(id)); let duration = 0; let critical: PhaseTransition['critical'] = null; let rangDesSiegers: Rangfolge | null = null; for (const toId of starting) { for (const fromId of ending) { const value = intergreenBetween(intergreens, fromId, toId); /* * NULL SEKUNDEN MACHEN KEINE MASSGEBENDE BEZIEHUNG, und das ist keine * Feinheit: `critical === null` heisst "keine Beziehung mit einer * Zwischenzeit ueber 0 s", und daran haengen die Uebergangszeile der * Ansicht und der Fehler zwischenzeiten.uebergang-ungesichert. Bis * 5.23.0 stand hier `value > duration` mit `duration` ab 0, und die * Bedingung fiel darin nebenbei ab. Beim Umbau auf den Rang ist sie * herausgefallen, und drei Wachen haben es gemeldet - sie steht * deshalb jetzt fuer sich. */ if (value <= 0) continue; if (value < duration) continue; const fromGroup = findSignalGroup(project, fromId); const toGroup = findSignalGroup(project, toId); const rang = rangDerBeziehung( intergreens, fromId, toId, value, fromGroup?.name ?? fromId, toGroup?.name ?? toId, ); if (rangDesSiegers !== null && vergleicheRang(rang, rangDesSiegers) <= 0) continue; duration = value; rangDesSiegers = rang; critical = { fromGroupId: fromId, fromGroupName: fromGroup?.name ?? fromId, toGroupId: toId, toGroupName: toGroup?.name ?? toId, intergreen: value, }; } } /* * Hier stand eine zweite Meldung zu genau derselben Bedingung. * * Sie erschien neben zwischenzeiten.uebergang-ungesichert der Pruefung - zu * demselben Uebergang, mit anderem Wortlaut, anderer Kategorie * (Signalzeitenplan statt Zwischenzeiten) und anderer Schwere (Warnung * statt Fehler). Zwei Meldungen zur selben Sache, die sich in der Bewertung * widersprechen, sind fuer den Bearbeiter nicht aufloesbar. * * Zustaendig ist die Pruefung: Sie kennt die erfassten Konfliktbeziehungen * und kann deshalb "gar nichts erfasst" von "erfasst und rechnerisch 0 s" * unterscheiden. Der Planaufbau kann das nicht. */ result.push({ fromPhaseId: from.id, toPhaseId: next.id, duration, critical }); } return result; } // --- Freigabezeitfenster je Signalgruppe ------------------------------------ function buildGroupWindows( project: Project, phases: readonly Phase[], phaseStarts: readonly Seconds[], durations: readonly Seconds[], transitions: readonly Omit[], transitionStarts: readonly Seconds[], intergreens: ReadonlyMap, cycleTime: Seconds, defaults: RilsaDefaults, notes: PlanNote[], ): PlannedSignalGroup[] { const n = phases.length; return project.signalGroups.map((group) => { const membership = phases.map((phase) => phase.signalGroupIds.includes(group.id)); const mindestfreigabezeit = mindestfreigabezeitDerGruppe(project, group, defaults); const times = resolveGroupTimes(group, mindestfreigabezeit, defaults); notes.push(...furtNotizen(group, mindestfreigabezeit)); notes.push(...vorgabeNotizen(group, mindestfreigabezeit)); const greens: GreenInterval[] = []; if (membership.every((m) => m)) { // Dauerfreigabe ueber den gesamten Umlauf. greens.push({ start: 0, duration: cycleTime }); } else if (membership.some((m) => m)) { for (const run of cyclicRuns(membership)) { const startPhase = run[0]!; const endPhase = run[run.length - 1]!; const previousIndex = (startPhase - 1 + n) % n; const transitionStart = transitionStarts[previousIndex] ?? 0; const transitionDuration = transitions[previousIndex]?.duration ?? 0; /* * Die Gruppe erhaelt Freigabe, sobald ihre eigene Zwischenzeit * abgelaufen ist - nicht erst nach der laengsten des Uebergangs. * * MIT EINEM VORBEHALT (Befund F6): Das gilt nur, wenn die unmittelbare * Vorphase ueberhaupt Signalgruppen fuehrt. Ist sie leer, endet dort * keine Gruppe, `required` bleibt 0 - und weil der Uebergang IN die * leere Phase bereits die volle Zwischenzeit traegt, laufen alle * Gruppen der Folgephase gemeinsam erst nach dieser vollen * Uebergangszeit an, auch die, die gar keine Zwischenzeit fordern. Das * ist die sichere, aber unwirtschaftliche Seite. */ const ending = phases[previousIndex]!.signalGroupIds.filter( (id) => !phases[startPhase]!.signalGroupIds.includes(id), ); let required = 0; for (const fromId of ending) { required = Math.max(required, intergreenBetween(intergreens, fromId, group.id)); } const rawStart = transitionStart + Math.min(required, transitionDuration); /* * ZUERST FALTEN, DANN MESSEN (Fassung 5.10.0): Beginn und Dauer * entstehen aus DEMSELBEN Wert. Ein Umlauf-Ueberlauf entsteht, wenn ein * Fenster ueber den Umlaufbeginn hinweg reicht; der Beginn wird dann in * den Umlauf zurueckgefaltet und die Dauer ueber die Umlaufgrenze * hinweg gemessen. * * Bis dahin wurde nur der Beginn gefaltet, die Dauer aber gegen den * ungefalteten `rawStart` gemessen. Endet der Lauf an der Umlaufgrenze * - alle folgenden Phasen leer (0 s) und der Uebergang aus der letzten * belegten Phase 0 s -, ist rawStart == end == tU: Der * Nicht-Ueberlauf-Zweig rechnete 0 s statt des ganzen Umlaufs. Die * Phasentabelle wies 30 s aus, die Signalgruppenzeile 0 s, und daraus * entstanden Scheinbefunde (Mindestfreigabezeit unterschritten, * Auslastungsgrad unendlich). * * Fuer jeden bisher richtigen Fall aendert die Faltung nichts: * rawStart liegt konstruktionsbedingt in [0, tU], und unterhalb von tU * ist sie die Identitaet. */ const start = modulo(rawStart, cycleTime); const end = (phaseStarts[endPhase] ?? 0) + (durations[endPhase] ?? 0); const duration = end >= start ? end - start : cycleTime - start + end; greens.push({ start: roundTo(start, 3), duration: roundTo(duration, 3), }); } } const totalGreen = greens.reduce((sum, g) => sum + g.duration, 0); const minGreen = times.minGreen; if (greens.length > 0 && totalGreen < minGreen) { notes.push({ severity: 'fehler', code: 'mindestfreigabezeit-unterschritten', signalGroupId: group.id, message: `Die Signalgruppe "${group.name}" erhält ${formatSeconds(totalGreen)} s Freigabezeit, ` + `mindestens erforderlich sind ${formatSeconds(minGreen)} s` + (mindestfreigabezeit.massgebend === 'furt' ? ' (aus der Furtlänge, siehe den Hinweis zur Mindestfreigabezeit).' : '.'), }); } if (greens.length === 0) { notes.push({ severity: 'warnung', code: 'signalgruppe-ohne-phase', signalGroupId: group.id, message: `Die Signalgruppe "${group.name}" ist keiner Phase zugeordnet und erhält keine Freigabezeit.`, }); } const verfahren = project.settings.serviceLevelScale; const demand = demandFor(project, group.id); const verkehrsstaerke = erfassteVerkehrsstaerke(demand); const sperrzeiten = sperrzeitenAusFenstern(greens, cycleTime); /* * KORREKTUR (Fassung 5.4.0, Befund B4): Fussgaenger und * Radverkehr werden nach HBS 2015 nicht ueber Saettigungsverkehrsstaerke, * Kapazitaet und mittlere Wartezeit bewertet, sondern ueber die maximale * Wartezeit (laengste Sperrzeit im Umlauf). Bis dahin liefen Radgruppen * mit einer Kfz-Saettigungsverkehrsstaerke auf der Kfz-Tafel, und * Fussgaengergruppen erhielten ueber einen Unendlich-Pfad formal die * Stufe F. Fuer beide gibt es jetzt weder Kapazitaet noch Auslastungsgrad; * die Bewertung entsteht aus den Signalzeiten allein und braucht keine * Verkehrsstaerke. */ if (group.mode === 'fuss' || group.mode === 'rad') { const delay = cycleTime > 0 ? computeFussRadDelay({ cycleTime, sperrzeiten, verfahren, mode: group.mode }) : null; return { groupId: group.id, name: group.name, mode: group.mode, color: group.color, times, mindestfreigabezeit, greens, totalGreen: roundTo(totalGreen, 3), capacity: null, delay, flowRatio: null, rueckstau: null, }; } /* * Der Schwerverkehrsanteil kommt aus dem Datensatz, die Nachfrage nur aus * einer ERFASSTEN Verkehrsstaerke (Befunde 12 und 18): Ein Datensatz, der * allein den Schwerverkehrs- oder Lastzuganteil traegt, ist keine * Zaehlung. Mit `demand?.volume` entstand daraus ein Auslastungsgrad 0,00, * und an dem hingen die beiden Meldungen, die die Luecke benennen. */ const saturation = groupSaturationFlow(group, demand, defaults); const capacity = cycleTime > 0 ? computeCapacity(totalGreen, cycleTime, saturation, verkehrsstaerke, { verfahren, freigabefenster: greens.length, maxDegreeOfSaturation: defaults.capacity.maxDegreeOfSaturation, }) : null; const delay = verkehrsstaerke !== undefined && capacity !== null ? computeDelay({ cycleTime, greenTime: totalGreen, capacity: capacity.capacity, demand: verkehrsstaerke, verfahren, mode: group.mode, abflusszeit: capacity.abflusszeit, longestRed: sperrzeiten.length > 0 ? Math.max(...sperrzeiten) : 0, /* * ALLE SPERRZEITEN, NICHT NUR DIE LAENGSTE (Fassung 5.10.0): Aus * ihnen bildet computeDelay den Zaehler der Grundwartezeit, * Summe(r_j^2) / tU. Ohne sie faellt es auf die geschlossene * Einfensterform zurueck - und die ist bei mehreren * Freigabezeitfenstern zu gross, bei zwei gleich langen Sperrzeiten * genau um das Doppelte. `longestRed` daneben ist die maximale * Wartezeit und eine andere Groesse; sie ersetzt die Uebergabe * nicht. * * Es sind DIESELBEN Sperrzeiten, aus denen die Fensterzahl der * Abflusszeit stammt (`freigabefenster` oben): Der Zeitzuschlag von * 1 s je Fenster steckt in beiden, und nur deshalb geht der * Einfensterfall unveraendert in die geschlossene Form ueber. */ sperrzeiten, instationaritaetsfaktor: defaults.capacity.instationaritaetsfaktor, }) : null; return { groupId: group.id, name: group.name, mode: group.mode, color: group.color, times, mindestfreigabezeit, greens, totalGreen: roundTo(totalGreen, 3), capacity, delay, flowRatio: verkehrsstaerke !== undefined ? roundTo(criticalFlowRatio(verkehrsstaerke, saturation), 4) : null, /* * Der Rueckstau als Laenge. Nur dort, wo die Bemessung ihn liefert: NGE * entsteht allein im HBS-Verfahren, und ohne Verkehrsstaerke oder * Umlaufzeit gibt es gar keine Wartezeitrechnung. Der Stauraum geht in * keine Zahl ein - er steht nur daneben. */ rueckstau: delay?.rechenweg.reststau === undefined || delay.rechenweg.reststau === null ? null : rueckstaubewertung(delay.rechenweg.reststau, group.lanes, group.stauraumlaenge ?? null), }; }); } /** Maximale zusammenhaengende Laeufe in einem zyklischen Wahrheitsfeld. */ export function cyclicRuns(membership: readonly boolean[]): number[][] { const n = membership.length; if (n === 0) return []; if (membership.every((m) => m)) return [Array.from({ length: n }, (_, i) => i)]; if (!membership.some((m) => m)) return []; // Startpunkt suchen, an dem ein Lauf beginnt (Vorgaenger ist false). const starts: number[] = []; for (let i = 0; i < n; i += 1) { if (membership[i] === true && membership[(i - 1 + n) % n] !== true) starts.push(i); } return starts.map((start) => { const run: number[] = []; let index = start; while (membership[index] === true && run.length < n) { run.push(index); index = (index + 1) % n; } return run; }); } // --- Hilfsgroessen ---------------------------------------------------------- /** * Furtlaenge einer Fussgaengergruppe: der laengste Raeumweg der * Konfliktbeziehungen, in denen die Gruppe RAEUMT (fromId = Gruppe). * * Der Raeumweg einer querenden Fussgaengergruppe ist der Weg vom Bordstein bis * zum Ende des Konfliktbereichs mit dem jeweiligen Fahrzeugstrom. Quert die * Furt mehrere Stroeme, ist der laengste dieser Wege die beste verfuegbare * Angabe zur Furtlaenge - eine eigene Furtlaenge fuehrt das Modell nicht. * Ohne solche Beziehung ist die Laenge unbekannt (`null`); geraten wird nicht. */ function furtlaengeDerGruppe(project: Project, group: SignalGroup): number | null { let laenge = 0; for (const conflict of project.conflicts) { if (conflict.fromId !== group.id) continue; if (isFiniteNumber(conflict.clearingDistance) && conflict.clearingDistance > laenge) { laenge = conflict.clearingDistance; } } return laenge > 0 ? laenge : null; } /** * Die massgebende Mindestfreigabezeit einer Signalgruppe. * * DIE EINE STELLE, aus der Freigabezeitverteilung (phaseMinGreen) UND * Pruefung (resolveGroupTimes -> mindestfreigabezeit-unterschritten) ihre * Untergrenze beziehen. Zwei Rechnungen nebeneinander liefen beim naechsten * Feld auseinander - und dann verteilte der Plan mit einer anderen Untergrenze, * als die Pruefung anlegt. * * Rechenweg (Fassung 5.4.0, Befund C1): * wert = max(Regelwert der Verkehrsart, Vorgabe an der Gruppe, Furtbedarf) * Furtbedarf (nur Fussgaenger mit erfasster Furtlaenge): * Furtweg / vr, Furtweg = Furtlaenge / 2, mit Zusatzeinrichtung fuer Blinde * und Sehbehinderte die ganze Furtlaenge; vr = REGELWERT der * Raeumgeschwindigkeit fuer die Gruppe (1,2 m/s, bei erhoehtem Zeitbedarf * 1,0 m/s) - aufgerundet auf ganze Sekunden. * * vr ist der Regelwert der Gruppe, NICHT zwingend die Geschwindigkeit der * Zwischenzeitrechnung: Dort kann an jeder Konfliktbeziehung eine * Ueberschreibung stehen (`clearingSpeedOverride`), die hier unberuecksichtigt * bleibt - die Furtregel fragt nach dem Fussgaenger auf der Furt, nicht nach * einer einzelnen Beziehung (der Kommentar behauptete vor der Fassung 5.4.0 * "dieselbe Geschwindigkeit wie beim Raeumen"). * * Beispiel: Furt 14 m, vr 1,2 m/s: halbe Furt 7 m -> 5,833 s -> 6 s (statt * 5 s Regelwert); mit Blindenzusatz 14 m -> 11,667 s -> 12 s. */ export function mindestfreigabezeitDerGruppe( project: Project, group: SignalGroup, defaults: RilsaDefaults, ): Mindestfreigabezeit { const regelwert = minGreenTime(group.mode, defaults); const override = group.minGreenOverride; const vorgabe = override !== null && isFiniteNumber(override) ? override : null; let furt: FurtFreigabebedarf | null = null; if (group.mode === 'fuss') { const furtlaenge = furtlaengeDerGruppe(project, group); if (furtlaenge !== null) { // Regelwert der Raeumgeschwindigkeit fuer diese Gruppe (Verkehrsart, // erhoehter Zeitbedarf) aus den Vorgaben. Ueberschreibungen an einzelnen // Konfliktbeziehungen (`clearingSpeedOverride`) bleiben unberuecksichtigt // - siehe Kopfkommentar. const vr = clearingSpeedFor( { mode: 'fuss', movement: group.movement, reducedMobility: group.reducedMobility }, defaults, ); furt = furtFreigabebedarf(furtlaenge, vr, group.blindenzusatz === true); } } // Ganze Sekunden, aufgerundet - die Rundungsregel steht EINMAL in // furtFreigabebedarf (signalTimes.ts), nicht hier. const furtWert = furt !== null ? furt.zeitbedarfGerundet : 0; let wert = regelwert; let massgebend: Mindestfreigabezeit['massgebend'] = 'regelwert'; // Eine kuerzere als die Mindestfreigabezeit wird nicht uebernommen. if (vorgabe !== null && vorgabe > wert) { wert = vorgabe; massgebend = 'vorgabe'; } if (furtWert > wert) { wert = furtWert; massgebend = 'furt'; } return { wert, regelwert, vorgabe, furt, massgebend }; } /** * Hinweise zum Furtbedarf einer Fussgaengergruppe - nachrechenbar, mit allen * Groessen im Satz. * * Zwei Faelle: Der Furtbedarf uebersteigt den Regelwert (dann steht der * Rechenweg da, auch wenn am Ende eine hoehere Vorgabe gewinnt); oder die * Furtlaenge ist unbekannt, weil die Gruppe in keiner Konfliktbeziehung mit * Raeumweg raeumt - dann kann die Furtregel nicht geprueft werden, und das * darf nicht wie "geprueft und in Ordnung" aussehen. */ function furtNotizen(group: SignalGroup, mfz: Mindestfreigabezeit): PlanNote[] { if (group.mode !== 'fuss') return []; if (mfz.furt === null) { return [ { severity: 'hinweis', code: 'mindestfreigabezeit-ohne-furtlaenge', signalGroupId: group.id, message: `Für die Fußgängergruppe "${group.name}" ist keine Konfliktbeziehung mit Räumweg erfasst, ` + 'in der sie räumt; die Furtlänge ist damit unbekannt. Die Furtregel der RiLSA (Freigabe ' + `mindestens für die halbe Furt) kann nicht geprüft werden; es gilt allein der Regelwert von ` + `${formatSeconds(mfz.regelwert)} s.`, }, ]; } const furt = mfz.furt; const furtWert = furt.zeitbedarfGerundet; if (furtWert <= mfz.regelwert) return []; const anteil = furt.ganzeFurt ? 'die ganze Furt (Zusatzeinrichtung für Blinde und Sehbehinderte, DIN 32981)' : 'die halbe Furt'; // Eine Vorgabe gewinnt nur, wenn sie den Furtbedarf nicht unterschreitet - // bei Gleichheit (Vorgabe 6 s, Furt 6 s) bleibt sie massgebend, "liegt aber // nicht darueber"; der Satz muss beide Faelle tragen. const ergebnis = mfz.massgebend === 'furt' ? `Die Mindestfreigabezeit beträgt deshalb ${formatSeconds(mfz.wert)} s statt des Regelwerts von ` + `${formatSeconds(mfz.regelwert)} s.` : mfz.wert > furtWert ? `Maßgebend bleibt die Vorgabe von ${formatSeconds(mfz.wert)} s, die darüber liegt.` : `Maßgebend bleibt die Vorgabe von ${formatSeconds(mfz.wert)} s, die den Furtbedarf genau deckt.`; // Furtweg mit zwei, Zeitbedarf mit drei Nachkommastellen: Bei einer Furt // von 12,01 m stuende sonst "6,01 m / 1,2 m/s = 5,00 s, aufgerundet 6 s" - // ein Rechenweg, der sein Ergebnis nicht hergibt. Mit 5,004 s ist die // Aufrundung nachvollziehbar. return [ { severity: 'hinweis', code: 'mindestfreigabezeit-aus-furtlaenge', signalGroupId: group.id, message: `Die Fußgängergruppe "${group.name}" quert eine Furt von ${formatMeters(furt.furtlaenge)} m ` + '(längster erfasster Räumweg). Nach RiLSA muss die Freigabezeit für ' + `${anteil} reichen: ${formatMetersFine(furt.furtweg)} m / ${formatSpeed(furt.vr)} m/s = ` + `${formatSecondsFine(furt.zeitbedarf)} s, aufgerundet ${formatSeconds(furtWert)} s. ${ergebnis}`, }, ]; } /** * Warnung, wenn die an der Gruppe eingetragene Mindestfreigabezeit UNTER dem * massgebenden Wert liegt und deshalb keine Wirkung hat. * * KORREKTUR (Fassung 5.4.0, Befund C9): mindestfreigabezeitDerGruppe * hebt eine zu kurze Vorgabe still auf den Regelwert (bzw. den Furtbedarf) - * fachlich richtig, aber der eingetragene Wert blieb im Feld stehen, und * nirgends stand, dass mit einem anderen gerechnet wird. Der Bearbeiter las * "3 s" und der Plan rechnete mit 5 s. Jetzt sagt der Bericht beides: was * eingetragen ist und was gilt. Eine Warnung, kein Fehler - das Ergebnis ist * richtig, nur die Eingabe ist irrefuehrend. * * Gemeldet wird jede Vorgabe unter dem MASSGEBENDEN Wert, nicht nur unter dem * Regelwert: Eine Vorgabe von 5,5 s an einer Furt mit 6 s Bedarf ist genauso * wirkungslos wie 3 s - und die Furtnotiz nennt die Vorgabe nicht. */ function vorgabeNotizen(group: SignalGroup, mfz: Mindestfreigabezeit): PlanNote[] { if (mfz.vorgabe === null || mfz.vorgabe >= mfz.wert) return []; const untergrenze = mfz.vorgabe < mfz.regelwert ? `den Regelwert der Verkehrsart von ${formatSeconds(mfz.regelwert)} s` : `den Furtbedarf von ${formatSeconds(mfz.wert)} s (siehe den Hinweis zur Mindestfreigabezeit)`; return [ { severity: 'warnung', code: 'mindestfreigabezeit-vorgabe-unterschritten', signalGroupId: group.id, message: `Für die Signalgruppe "${group.name}" ist eine Mindestfreigabezeit von ` + `${formatSeconds(mfz.vorgabe)} s eingetragen; sie unterschreitet ${untergrenze}. ` + `Maßgebend bleibt ${formatSeconds(mfz.wert)} s; die Vorgabe hat keine Wirkung.`, }, ]; } function resolveGroupTimes( group: SignalGroup, mindestfreigabezeit: Mindestfreigabezeit, defaults: RilsaDefaults, ): SignalGroupTimes { const base = signalGroupTimes(group.mode, group.vZul, defaults); return { ...base, minGreen: mindestfreigabezeit.wert }; } /** * Hat die Verkehrsart eine Saettigungsverkehrsstaerke im Sinne des HBS? * Fussgaenger und Radverkehr werden an Lichtsignalanlagen nicht ueber * Kapazitaet und Auslastungsgrad bewertet (HBS 2015; Fassung 5.4.0, * Befund B4); ihre Verkehrsstaerken gehen deshalb weder in Kapazitaet noch in * die Umlaufzeitermittlung ein. */ export function hatSaettigungsverkehrsstaerke(mode: SignalGroup['mode']): boolean { return mode === 'kfz' || mode === 'oepnv'; } /** * Die erfasste Verkehrsstaerke eines Stroms - oder `undefined`, wenn keine * erfasst ist. * * DAS EINE KRITERIUM (Fassung 5.10.0): Ein DATENSATZ ist noch keine Zaehlung. * `actions.setDemand` und `actions.setLastzugAnteil` legen einen Datensatz mit * `volume: 0` an, sobald in der Signalgruppentabelle nur die Spalte "SV-Anteil" * oder "Lastzuege am SV" ausgefuellt wird. Bis dahin entschieden * `phaseFlowRatio` und die Kapazitaetsrechnung allein am Vorhandensein des * Datensatzes: Die Phase kam mit dem Saettigungsgrad 0 statt als Luecke * zurueck, und `computeCapacity` lieferte einen gesetzten Auslastungsgrad 0,00. * Damit fielen beide Meldungen weg, die die fehlende Zahl benennen * ('saettigungsgrade-unvollstaendig' und * 'leistungsfaehigkeit.keine-verkehrsstaerken') - waehrend Y zu klein blieb und * die Umlaufzeit zu kurz ausfiel, also in die gefaehrliche Richtung. * * `volume <= 0` heisst deshalb hier dasselbe wie in der Pruefung der * uebersaettigten Stroeme (validation/rules.ts), in der Simulation * (simulation/simulation.ts) und in der Fuehrung (ui/naechsterSchritt.ts): * keine Angabe. "Gezaehlt und 0 Fz/h" laesst sich davon nicht unterscheiden - * beide Bedienwege erzeugen denselben Datensatz -, und eine massgebende * Kfz-Verkehrsstaerke von 0 ist kein Bemessungsfall; nach der Zaehlung zu * fragen ist die sichere Richtung. Ein unbrauchbarer Wert (nicht endlich, * negativ) faellt in dieselbe Behandlung und wird ueber die beiden Meldungen * sichtbar, nicht stumm gerechnet. * * SO WEIT UND NICHT WEITER: Diese Lesart gilt fuer die Leser dieser Funktion - * Planaufbau, Umlaufzeit und Leistungsfaehigkeit -, nicht im ganzen Programm. * Die Anordnungskriterien der RSA 21 (validation/rules.ts, einstreifigBefunde) * zaehlen weiter jeden Datensatz mit und schreiben danach "zusammen 0 Kfz/h" * statt "nicht erfasst"; das liegt ausserhalb dieser Behebung und ist gemeldet. * * DIE AUSGABEN SIND NACHGEZOGEN (Fassung 5.10.0): Hier stand daneben, * Planunterlage und Tabellenausgabe gaeben die Spalte Verkehrsstaerke * unveraendert als "0 Fz/h" aus. Das gilt fuer keine von beiden mehr - der * Ausdruck setzt an allen drei Stellen einen Gedankenstrich * (services/export/pdf.ts), die Tabellenausgabe laesst die Zelle leer * (services/export/csv.ts). */ function erfassteVerkehrsstaerke(demand: TrafficDemand | undefined): number | undefined { if (demand === undefined) return undefined; if (!isFiniteNumber(demand.volume) || demand.volume <= 0) return undefined; return demand.volume; } /* * Schwerverkehrsanteil, mit dem gerechnet wird: der eingetragene Anteil, * begrenzt auf 0 bis 1 - `wirksamerSchwerverkehrsanteil` aus rilsa/capacity.ts. * * KORREKTUR (Fassung 5.4.0): Hier stand `Math.min(0.9, ...)`. Das Schema laesst * bis 1,0 zu, die Ansicht zeigte 95 %, gerechnet wurde mit 90 % - ohne Meldung. * Fuer die Kappung bei 0,9 gab es keinen fachlichen Grund: Ein Strom aus lauter * Bussen hat 100 % Schwerverkehr, und base / (1 + 1) ist eine ordentliche Zahl. * Jetzt gilt der Bereich des Schemas, 0 bis 1; die Ansicht laesst 0 bis 100 % * zu. Was darueber hinausgeht (nur an der Datei vorbei erreichbar), wird * begrenzt und von schwerverkehrNotizen gemeldet - kein stiller Ersatz. * * ZWEITE KORREKTUR (Fassung 5.6.0): Die Funktion stand hier als eigene Fassung * derselben Grenze, waehrend der Pruefbericht eine dritte fuehrte - naemlich * gar keine. Sie steht jetzt neben der Gleichung, die sie braucht. */ /** * Warnung je Nachfrage, deren Schwerverkehrsangaben nicht verwendbar sind. * * Zwei Faelle, zwei Folgen: Ein Schwerverkehrsanteil ausserhalb von 0 bis 1 * wird BEGRENZT und mit dem begrenzten Wert gerechnet (siehe * wirksamerSchwerverkehrsanteil); ein unbrauchbarer Lastzuganteil wird * VERWORFEN und der Strom nach Gl. 2-6 gerechnet (siehe istLastzuganteil in * rilsa/capacity.ts). Beides steht im Bericht - kein stiller Ersatz. */ function schwerverkehrNotizen(project: Project): PlanNote[] { const notizen: PlanNote[] = []; for (const demand of project.demands) { const group = findSignalGroup(project, demand.signalGroupId); if (!group || !hatSaettigungsverkehrsstaerke(group.mode)) continue; /* * Ein eingetragener, aber unbrauchbarer Lastzuganteil. Er faellt auf * Gl. 2-6 zurueck - dieselbe stille Ersetzung, die beim * Schwerverkehrsanteil beanstandet und behoben worden ist, nur eine Zeile * weiter: Der Bearbeiter hat eine Aufteilung eingetragen, gerechnet wird * ohne sie, und ohne diese Notiz stuende davon nichts im Bericht. Der * Pruefbericht meldet dann zwar den Pauschalwert, aber mit dem Satz "keine * Aufteilung erfasst" - und das waere aus Sicht des Bearbeiters schlicht * falsch. * * Ueber die Datei erreichbar ist der Fall nicht (schema.ts verwirft solche * Eintraege bereits beim Lesen und meldet sie); ueber die Oberflaeche und * ueber Fremdwerkzeuge, die ein Projekt zusammenbauen, sehr wohl. * * DASS DIE OBERFLAECHE IHN ERREICHT, IST EINE ENTSCHEIDUNG UND KEIN ZUFALL: * Das Feld "Lastzuege am SV" reicht einen Wert ausserhalb von 0 bis 100 % * unbegrenzt weiter (signalGroupsView.ts, `ausserhalbUebernehmen`). Waere * er dort geklemmt, wuerde aus -30 % ein gezaehltes "kein einziger * Lastzug", diese Notiz entstuende nie, und sie waere ausser von den Tests * von niemandem mehr erreichbar. */ if (demand.lastzugAnteil !== undefined && !istLastzuganteil(demand.lastzugAnteil)) { notizen.push({ severity: 'warnung', code: 'lastzuganteil-ausserhalb', signalGroupId: group.id, message: `Für die Signalgruppe "${group.name}" ist ein Lastzuganteil am Schwerverkehr von ` + `${isFiniteNumber(demand.lastzugAnteil) ? `${formatSeconds(demand.lastzugAnteil * 100)} %` : 'kein gültiger Zahlenwert'} ` + 'eingetragen; zulässig sind 0 bis 100 %. Die Aufteilung des Schwerverkehrs gilt damit ' + 'als nicht erfasst; gerechnet wird mit dem Pauschalwert des HBS 2015 (1,90 je ' + 'Schwerfahrzeug).', }); } const wirksam = wirksamerSchwerverkehrsanteil(demand.heavyVehicleShare); if (wirksam === demand.heavyVehicleShare) continue; const eingetragen = isFiniteNumber(demand.heavyVehicleShare) ? `${formatSeconds(demand.heavyVehicleShare * 100)} %` : 'kein gültiger Zahlenwert'; notizen.push({ severity: 'warnung', code: 'schwerverkehrsanteil-ausserhalb', signalGroupId: group.id, message: `Für die Signalgruppe "${group.name}" ist ein Schwerverkehrsanteil von ${eingetragen} ` + `eingetragen; zulässig sind 0 bis 100 %. Gerechnet wird mit ${formatSeconds(wirksam * 100)} %.`, }); } return notizen; } /** * Saettigungsverkehrsstaerke einer Signalgruppe einschliesslich Schwerverkehr. * * KORREKTUR (Fassung 5.5.0): Hier stand `base / (1 + share)` mit dem Kommentar, * ein Pkw-Aequivalent von 2,0 sei "der uebliche Ansatz". Es war der einzige * Kennwert, der nicht in constants.ts stand - und er ist nachweislich der Wert * des HCM 2010, waehrend das Programm ihn unter der HBS-Kapazitaetsquelle * fuehrte. Gerechnet wird jetzt mit dem Anpassungsfaktor fSV des HBS 2015 * (rilsa/capacity.ts, schwerverkehrsfaktor; Pkw-Gleichwerte in constants.ts, * HBS_PKW_GLEICHWERTE). * * WELCHE DER BEIDEN GLEICHUNGEN GILT, ENTSCHEIDET DIE DATENLAGE DIESES STROMS * (Schema 13): Ist an der Verkehrsstaerke ein Lastzuganteil erfasst * (`TrafficDemand.lastzugAnteil`), rechnet dieser Strom nach Gl. 2-5 mit den * Pkw-Gleichwerten 1,75 und 2,50; sonst nach Gl. 2-6 mit dem Pauschalwert 1,90. * Genau diese Fallunterscheidung sieht das HBS 2015 vor - "in Abhaengigkeit von * der Datenverfuegbarkeit zur Aufteilung des Schwerverkehrs". * * Beide Wege koennen im selben Plan nebeneinander stehen, und das ist kein * Widerspruch, sondern die Wahrheit ueber die Eingabe: An der einen Zufahrt * wurde gezaehlt, an der anderen nicht. Der Pruefbericht sagt fuer jeden Strom, * welcher Weg gilt. */ /** * Die erfasste Geometrie einer Signalgruppe fuer die Kapazitaetsrechnung. * * Die Umsetzung steht hier und nicht in `rilsa/capacity.ts`: Der Fachkern * kennt das Projektmodell nicht, und er soll es nicht kennen. Ansicht und * Ausdruck nehmen dieselbe Stelle, damit angezeigte, gedruckte und gerechnete * Geometrie nicht auseinanderlaufen koennen. */ export function fahrstreifengeometrie(group: SignalGroup): Fahrstreifengeometrie { return { breite: group.fahrstreifenbreite, radius: group.kurvenradius, neigung: group.laengsneigung, }; } function groupSaturationFlow( group: SignalGroup, demand: TrafficDemand | undefined, defaults: RilsaDefaults, ): number { if (!hatSaettigungsverkehrsstaerke(group.mode)) return Number.POSITIVE_INFINITY; const base = saturationFlow(group.movement, group.lanes, defaults, fahrstreifengeometrie(group)); const share = wirksamerSchwerverkehrsanteil(demand?.heavyVehicleShare ?? 0); // Der begrenzte Anteil geht auch in die Aufteilung ein - sonst rechnete // Gl. 2-5 mit einem pSV, das Gl. 2-6 an derselben Stelle bereits verworfen // hat, und zwei Stroeme mit demselben Eintrag kaemen auf verschiedene Wege. return ( base / schwerverkehrsfaktor(share, aufteilungAusLastzuganteil(share, demand?.lastzugAnteil)) ); } /** * Massgebender Saettigungsgrad einer Phase - oder `undefined`, wenn er sich * nicht bilden laesst, weil keine Verkehrsstaerke erfasst ist. * * KORREKTUR (Fassung 5.9.0): Hier stand `let max = 0` und ein * `continue` fuer jede Gruppe ohne Verkehrsstaerke. Die Phase kam damit mit dem * Saettigungsgrad 0 aus der Rechnung - genau wie eine Phase, deren Verkehr * gezaehlt und zu 0 befunden wurde. Die Umlaufzeitermittlung konnte die Luecke * gar nicht sehen; ihre Meldung griff nur bei einer LEEREN Liste. Die * ermittelte Umlaufzeit fiel dadurch zu kurz aus. * * Unterschieden wird nach der Datenlage, nicht nach dem Ergebnis: * - Die Phase fuehrt Signalgruppen mit Saettigungsverkehrsstaerke, zu keiner * davon ist eine Verkehrsstaerke eingetragen -> `undefined` (Luecke). * - Die Phase fuehrt ueberhaupt keine solche Signalgruppe (reine * Fussgaengerphase) -> 0. Das ist der Regelfall und keine Luecke. * * WAS "EINGETRAGEN" HEISST, entscheidet `erfassteVerkehrsstaerke` * (Fassung 5.10.0): das Vorhandensein des Datensatzes genuegte * nicht - ein allein ueber den Schwerverkehrsanteil entstandener Datensatz * traegt `volume: 0` und schaltete diese Luecke ab. * * Das Gewicht der Phase in `distributeGreenTimes` bleibt `?? 0`: Fuer die * Verteilung ist eine nicht erfasste Phase so gut wie eine ohne Verkehr. */ function phaseFlowRatio( project: Project, phase: Phase, defaults: RilsaDefaults, ): Ratio | undefined { let max = 0; let mitSaettigungsverkehrsstaerke = false; let erfasst = false; for (const id of phase.signalGroupIds) { const group = findSignalGroup(project, id); if (!group || !hatSaettigungsverkehrsstaerke(group.mode)) continue; mitSaettigungsverkehrsstaerke = true; const demand = demandFor(project, id); const volume = erfassteVerkehrsstaerke(demand); if (volume === undefined) continue; erfasst = true; const saturation = groupSaturationFlow(group, demand, defaults); const ratio = criticalFlowRatio(volume, saturation); if (Number.isFinite(ratio)) max = Math.max(max, ratio); } if (mitSaettigungsverkehrsstaerke && !erfasst) return undefined; return max; } /** * Mindestfreigabezeit einer Phase: die groesste ihrer Signalgruppen - aus * derselben Rechnung wie die Pruefung je Gruppe (Fassung 5.4.0, * Befund C1). Bis dahin stand hier eine zweite Fassung von * max(Regelwert, Vorgabe) ohne die Furtregel; die Verteilung haette eine Furt * mit 5 s bedient, die die Pruefung danach mit "mindestens 6 s" beanstandet. */ function phaseMinGreen(project: Project, phase: Phase, defaults: RilsaDefaults): Seconds { let max = 0; for (const id of phase.signalGroupIds) { const group = findSignalGroup(project, id); if (!group) continue; max = Math.max(max, mindestfreigabezeitDerGruppe(project, group, defaults).wert); } return max; } /** * Die feste Freigabezeit einer Phase, sonst null. * * Eine von Hand gesetzte Freigabezeit gilt nur, wenn sie die * Mindestfreigabezeit der Phase einhaelt; darunter setzt die Verteilung die * Mindestfreigabezeit an und meldet das als 'phase-freigabezeit-zu-kurz'. * Genau diese Bedingung entscheidet zugleich darueber, ob die * Hoechstfreigabezeit der Signalgruppen zur Wirkung kommt - deshalb steht sie * hier einmal und wird an beiden Stellen gefragt. */ function festeFreigabezeit(phase: Phase, minGreen: Seconds): Seconds | null { const manual = phase.manualGreen; if (manual === null || !isFiniteNumber(manual) || manual < minGreen) return null; return manual; } /** * Hoechstfreigabezeit einer Phase: die KLEINSTE Vorgabe ihrer Signalgruppen, * sonst der Regelwert aus den Projektvorgaben. * * DIE VORGABE JE SIGNALGRUPPE WIRKT (Fassung 5.10.0): Hier stand fuer jede * Phase unbedingt `defaults.maxGreen`. `SignalGroup.maxGreenOverride` wurde * eingelesen, aus 4.x uebernommen ("damit bewusst gesetzte Werte nicht verloren * gehen", migrate.ts) und beim Speichern zurueckgeschrieben - gelesen hat es * niemand. Die Behandlung war damit genau verkehrt herum: Ein UNLESBARER * Eintrag wurde beim Einlesen gemeldet ("Gerechnet wird ohne diese obere * Schranke - pruefen Sie die Signalgruppe.", schema.ts), ein LESBARER stumm * verworfen. Diese Meldung setzt voraus, dass ein lesbarer Wert als obere * Schranke wirkt. * * WARUM DER KLEINSTE: Alle Signalgruppen einer Phase erhalten dieselbe * Freigabezeit. Eine Obergrenze ist nur dann bei allen eingehalten, wenn die * kleinste eingehalten ist. Ein Wert oberhalb des Regelwerts bindet nicht - * dann haelt schon der Regelwert ihn ein. * * WARUM DIE MINDESTFREIGABEZEIT VORGEHT: Sie ist die harte Schranke der RiLSA; * eine Obergrenze darunter waere nicht schaltbar. Die Verteilung setzt in * diesem Fall ohnehin die Untergrenze an - hier wird die Vorgabe zusaetzlich * als wirkungslos gemeldet, statt still uebergangen zu werden. Dieselbe * Behandlung wie bei der Mindestfreigabezeit unter dem Regelwert * ('mindestfreigabezeit-vorgabe-unterschritten', vorgabeNotizen). * * Ein unbrauchbarer Eintrag (nicht endlich) gilt als nicht gesetzt; das * Einlesen hat ihn dann bereits gemeldet. Ein negativer laeuft in dieselbe * Meldung wie eine zu kleine Vorgabe. * * ANGESETZT WIRD DIESE SCHRANKE NICHT, wo die Phase eine feste Freigabezeit * traegt: Dort setzt der Aufrufer minGreen = maxGreen = Vorgabe. Die * unmittelbare Angabe zur Phase geht der abgeleiteten Schranke ihrer * Signalgruppen vor - so wie sie auch der Verteilung nach Verkehrsstaerken * vorgeht. GEMELDET wird sie dort trotzdem, mit eigenem Satz * ('hoechstfreigabezeit-unter-fester-freigabezeit'): Vor der Fassung 5.10.0 * entstand hier fuer jede Phase dieselbe Meldung wie ohne Handvorgabe - sie * behauptete "Massgebend bleibt die Mindestfreigabezeit", waehrend die feste * Freigabezeit galt, und lief mit dieser falschen Aussage ueber den * Pruefbericht in die gedruckte Planunterlage. Lag die Vorgabe zwischen * Mindest- und fester Freigabezeit, verfiel sie umgekehrt ganz stumm * - genau der Zustand, den die Befunde 36 und 42 beanstanden. Massstab ist * deshalb der Wert, der ohne die Vorgabe der Signalgruppen gaelte: die feste * Freigabezeit, sonst der Regelwert. * * AUCH DER PROJEKTVORGABEWERT WIRD ANGEHOBEN UND GEMELDET (Fassung 5.13.0). Bis * dahin kehrte die Funktion bei `massgebend === null` mit dem unveraenderten * Regelwert zurueck. Lag DIESER unter der Mindestfreigabezeit der Phase, * geschah zweierlei: * * - Der Anwender erfuhr nichts. Fuer denselben Sachverhalt - eine * Hoechstfreigabezeit unter der Mindestfreigabezeit - gab es eine Meldung, * wenn die Zahl an einer Signalgruppe stand, und keine, wenn sie in der * Vorgabenverwaltung stand. * - Die Verteilung bekam eine Phase mit maxGreen < minGreen. An den * Freigabezeiten aendert das nichts - die Mindestfreigabezeit geht ihnen * vor -, wohl aber an der Auskunft daneben, und die geht ueber den * Pruefbericht in die gedruckte Planunterlage. Gemessen an der * Standardvorlage mit minGreenKfz 40 s und Hoechstfreigabezeit 10 s: tU * 100 s und 40/40 s vorher wie nachher, davor jedoch mit der Warnung * 'freigabezeit-rest' - "Alle Phasen erreichen ihre Höchstfreigabezeit; * 30 s der Umlaufzeit bleiben übrig ... von 10 s auf 40 s" - bei * Freigabezeiten von 40 s gegen eine angebliche Obergrenze von 10 s. * Dieselbe Vorlage mit einer dritten, reinen Fussgaengerphase und fester * Umlaufzeit von 120 s fuehrt vorher wie nachher 60/40/10 s; die * Restmeldung nannte dort davor "50 s ... von 10 s auf 60 s", waehrend es * 20 s und "von 40 s auf 60 s" sind. Die Annahme, der BERICHT sei in * beiden Faellen derselbe, traegt also nicht. * * NACHTRAG (10.09.2026, Fassung 5.23.0): Beide Zahlenreihen dieses * Absatzes gelten dem Stand bis 5.22.0 und sind hier stehengeblieben, weil * sie den damaligen Befund tragen. Heute sind es 40/40/30 s, und die * Restmeldung nennt keine Verlaengerung "von ... auf ..." mehr, sondern die * Phasen, die den Rest bekommen. Der Rest geht seither nicht mehr ganz an * eine Phase (greenSplit.ts). * * Angehoben wird deshalb hier, gleich der Behandlung der Signalgruppenvorgabe. * GEMELDET wird dagegen nicht hier: Der Regelwert ist EINE Zahl, und eine * Meldung je Phase saehe im Bericht wie mehrere Befunde aus. Die Funktion * vermerkt den Fall nur; den einen Satz dazu setzt * `vorgabeHoechstfreigabezeitNotiz`. */ interface Hoechstfreigabezeit { /** Obere Schranke, mit der die Phase in die Freigabezeitverteilung geht. */ readonly wert: Seconds; /** * Der Regelwert aus den Projektvorgaben liegt unter der Mindestfreigabezeit * dieser Phase und bleibt deshalb ohne Wirkung. */ readonly vorgabeOhneWirkung: boolean; } function phaseMaxGreen( project: Project, phase: Phase, defaults: RilsaDefaults, minGreen: Seconds, fest: Seconds | null, notes: PlanNote[], ): Hoechstfreigabezeit { const ohneVorgabe = fest ?? defaults.maxGreen; let wert = ohneVorgabe; let massgebend: SignalGroup | null = null; for (const id of phase.signalGroupIds) { const group = findSignalGroup(project, id); if (!group) continue; const vorgabe = group.maxGreenOverride; if (vorgabe === null || !isFiniteNumber(vorgabe) || vorgabe >= wert) continue; wert = vorgabe; massgebend = group; } /* * ALLE, DIE DENSELBEN WERT TRAGEN, und nicht nur die erste (Fassung 5.24.0). * Der Vergleich oben uebernimmt nur einen echt kleineren Wert; tragen zwei * Signalgruppen derselben Phase dieselbe Zahl, blieb die zuerst zugeordnete * stehen - und allein ihr Name ging in die Warnung. Wer die Zuordnung in der * Phasenansicht neu setzte, bekam dieselbe Warnung ueber die andere Gruppe, * bei unveraendertem Plan. Beanstandet gehoert jede Vorgabe, die ohne * Wirkung bleibt. * * Die Sprungmarke bleibt bei einer Gruppe: Der Bericht springt an eine * Stelle, nicht an drei. Genannt werden alle. */ const gleichauf: SignalGroup[] = []; if (massgebend !== null) { for (const id of phase.signalGroupIds) { const group = findSignalGroup(project, id); if (group && isFiniteNumber(group.maxGreenOverride) && group.maxGreenOverride === wert) { gleichauf.push(group); } } } // Nach Namen und nicht in der Zuordnungsreihenfolge: Sonst stuende dieselbe // Warnung nach einem Neuzuordnen der Gruppen mit vertauschten Namen da. const betroffene = gleichauf.length > 1 ? `Signalgruppen ${[...gleichauf] .sort((a, b) => a.name.localeCompare(b.name, 'de')) .map((g) => `"${g.name}"`) .join(' und ')} ist` : `Signalgruppe "${massgebend?.name ?? ''}" ist`; if (massgebend === null) { // Bei fester Freigabezeit ist der Regelwert nicht die Schranke der Phase; // dort gilt die Handvorgabe, und die haelt die Mindestfreigabezeit schon // nach festeFreigabezeit ein. if (fest === null && ohneVorgabe < minGreen) { return { wert: minGreen, vorgabeOhneWirkung: true }; } return { wert: ohneVorgabe, vorgabeOhneWirkung: false }; } if (fest !== null) { notes.push({ severity: 'warnung', code: 'hoechstfreigabezeit-unter-fester-freigabezeit', signalGroupId: massgebend.id, message: `Für die ${betroffene} eine Höchstfreigabezeit von ` + `${formatSeconds(wert)} s eingetragen; für "${phase.name}" ist eine feste Freigabezeit ` + `von ${formatSeconds(fest)} s vorgegeben. Maßgebend bleibt die feste Freigabezeit der ` + 'Phase; die Vorgabe hat keine Wirkung.', }); return { wert: fest, vorgabeOhneWirkung: false }; } if (wert < minGreen) { notes.push({ severity: 'warnung', code: 'hoechstfreigabezeit-unter-mindestfreigabezeit', signalGroupId: massgebend.id, message: `Für die ${betroffene} eine Höchstfreigabezeit von ` + `${formatSeconds(wert)} s eingetragen; "${phase.name}" braucht mindestens ` + `${formatSeconds(minGreen)} s. Maßgebend bleibt die Mindestfreigabezeit; die Vorgabe ` + 'hat keine Wirkung.', }); return { wert: minGreen, vorgabeOhneWirkung: false }; } return { wert, vorgabeOhneWirkung: false }; } /** * Der EINE Satz zu einer Hoechstfreigabezeit aus den Projektvorgaben, die * unter der Mindestfreigabezeit einer Phase liegt. * * WARUM EINMAL UND NICHT JE PHASE: Der Regelwert ist eine einzige Zahl aus der * Vorgabenverwaltung. Liegt er unter den Mindestfreigabezeiten von acht * Phasen, ist das eine irrefuehrende Eingabe und nicht acht - ein Bericht, der * achtmal denselben Satz fuehrt, verdeckt die uebrigen Befunde. Die * betroffenen Phasen stehen deshalb mit ihrer Mindestfreigabezeit im Satz. * * WARUM KEINE EIGENE KENNUNG: Der Sachverhalt ist derselbe, den die * Signalgruppenvorgabe unter der Mindestfreigabezeit ausloest - Ueberschrift * ("Höchstfreigabezeit unter der Mindestfreigabezeit") und Einstufung des * Pruefberichts passen woertlich. Eine neue Kennung fiele in der Engine auf * die Rueckfalltexte zurueck ("Hinweis aus dem Planaufbau"), und das waere * fuer den Leser schlechter. Der Satz nennt die Vorgabenverwaltung selbst, * damit niemand an einer Signalgruppe nach einem Eintrag sucht, den es dort * nicht gibt. * * WARUM WARNUNG UND NICHT FEHLER: Das Ergebnis ist richtig - die * Mindestfreigabezeit geht vor. Irrefuehrend ist allein die Eingabe. Dieselbe * Einstufung wie bei 'mindestfreigabezeit-vorgabe-unterschritten'. */ function vorgabeHoechstfreigabezeitNotiz( phases: readonly Phase[], phaseMinGreens: readonly Seconds[], hoechstfreigabezeiten: readonly Hoechstfreigabezeit[], defaults: RilsaDefaults, ): PlanNote[] { const betroffen = phases .map((phase, index) => ({ phase, minGreen: phaseMinGreens[index] ?? 0, index })) .filter((eintrag) => hoechstfreigabezeiten[eintrag.index]?.vorgabeOhneWirkung === true); if (betroffen.length === 0) return []; const liste = betroffen .map((eintrag) => `"${eintrag.phase.name}" (${formatSeconds(eintrag.minGreen)} s)`) .join(', '); const mehrere = betroffen.length > 1; return [ { severity: 'warnung', /* * EIGENER KODE, nicht der der Signalgruppenvorgabe. Beide Faelle sind * derselbe Sachverhalt, aber der naechste Schritt ist ein anderer: Dort * ist an einer Signalgruppe etwas eingetragen, das zu entfernen oder * anzuheben ist; hier steht die Zahl in der Vorgabenverwaltung, und an * keiner Signalgruppe ist etwas zu finden. Der Rat zu * 'hoechstfreigabezeit-unter-mindestfreigabezeit' schickte den Bearbeiter * an eine Signalgruppe, an der nichts steht - `suggestionFor` * (domain/validation/engine.ts) kennt nur den Kode, nicht die Meldung, * und kann die beiden Faelle deshalb nicht auseinanderhalten. */ code: 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit', message: `In den Projektvorgaben steht eine Höchstfreigabezeit von ` + `${formatSeconds(defaults.maxGreen)} s. ` + `${mehrere ? 'Diese Phasen brauchen' : 'Diese Phase braucht'} mehr: ${liste}. ` + `Maßgebend bleibt dort die Mindestfreigabezeit; die Vorgabe hat ` + `${mehrere ? 'für diese Phasen' : 'für diese Phase'} keine Wirkung.`, }, ]; } /** * Umlaufzeit ermitteln oder uebernehmen. * * Die Umlaufzeitermittlung in rilsa/cycle.ts kennt die Anlagenart nicht - sie * rastert, begrenzt und beurteilt den Regelbereich ausschliesslich anhand von * `defaults.cycleTime`. Ihr wird deshalb ein Kennwertsatz uebergeben, dessen * Umlaufzeitbereich der der Anlagenart ist. Die uebrigen Kennwerte bleiben * unveraendert, und `plan.defaults` behaelt den unverfaelschten Satz aus der * Vorgabenverwaltung. */ function resolveCycleTime( project: Project, input: Parameters[1], defaults: RilsaDefaults, grenzen: AnlagenartGrenzen, notes: PlanNote[], ): { cycleTime: Seconds; cycleResult: CycleTimeResult | null; cycleComparison: CycleTimeComparison; } { const cycleDefaults: RilsaDefaults = { ...defaults, cycleTime: { ...defaults.cycleTime, ...grenzen.cycleTime }, }; /* * DER VERGLEICH WIRD IMMER GERECHNET - auch bei fester Vorgabe. Gerade dort * ist er die Auskunft, die fehlte: Wer eine Umlaufzeit von Hand setzt, sieht * jetzt, was die vier Ansaetze aus denselben Eingaben machen, und kann seine * Wahl begruenden. * * ER BRINGT KEINE EIGENEN MELDUNGEN IN DEN PLAN. `notes` bekommt weiterhin * nur die Meldungen des GEWAEHLTEN Verfahrens; die drei anderen rechnen an * einer Umlaufzeit, die niemand schaltet, und ihre Beanstandungen gehoeren * nicht in den Pruefbericht dieser Planung. Sie stehen im Vergleich und * werden dort neben ihrem Verfahren ausgewiesen. * * Dieselben Eingaben, dieselben Schranken: `input` und `cycleDefaults` sind * die, mit denen auch das gewaehlte Verfahren rechnet. Das gewaehlte * Ergebnis wird unten AUS dem Vergleich genommen und nicht ein zweites Mal * gerechnet - sonst koennten Tabelle und Plan auseinanderlaufen. */ const cycleComparison = compareCycleTimeMethods(input, cycleDefaults); if (project.program.method === 'manuell') { const manual = project.program.manualCycleTime; if (manual === null || !isFiniteNumber(manual) || manual <= 0) { notes.push({ severity: 'fehler', code: 'umlaufzeit-fehlt', message: 'Es ist eine feste Umlaufzeit gewählt, aber kein Wert eingetragen. ' + `Angesetzt wird die Mindestumlaufzeit von ${formatSeconds(input.minimumCycle ?? grenzen.cycleTime.min)} s.`, }); return { cycleTime: input.minimumCycle ?? grenzen.cycleTime.min, cycleResult: null, cycleComparison, }; } const minimum = input.minimumCycle ?? 0; if (manual < minimum) { notes.push({ severity: 'fehler', code: 'umlaufzeit-zu-kurz', message: `Die vorgegebene Umlaufzeit von ${formatSeconds(manual)} s reicht nicht aus. ` + `Aus Mindestfreigabezeiten und Zwischenzeiten ergeben sich mindestens ` + `${formatSeconds(minimum)} s.`, }); return { cycleTime: minimum, cycleResult: null, cycleComparison }; } /* * KORREKTUR (Fassung 5.4.0, Befund C4): Der berechnete Zweig * meldet eine Umlaufzeit ausserhalb des Regelbereichs (cycle.ts), der * feste Wert lief ohne diesen Hinweis durch - 120 s von Hand am * Knotenpunkt blieben stumm, waehrend dieselben 120 s aus der Rechnung * gemeldet wurden. Dieselbe Meldung mit demselben Wortlaut, gegen dieselben * Schranken der Anlagenart. Ein Hinweis, keine Schranke: Der Regelbereich * ist eine Empfehlung, die harten Grenzen prueft der Bericht getrennt. */ const { recommendedMin, recommendedMax } = grenzen.cycleTime; if (manual < recommendedMin || manual > recommendedMax) { notes.push({ severity: 'hinweis', code: 'umlaufzeit-ausserhalb-regelbereich', message: `Die Umlaufzeit ${formatSeconds(manual)} s liegt außerhalb des üblichen Bereichs von ` + `${formatSeconds(recommendedMin)} bis ${formatSeconds(recommendedMax)} s.`, }); } return { cycleTime: manual, cycleResult: null, cycleComparison }; } const result = cycleComparison.results[project.program.method]; notes.push(...result.notes); return { cycleTime: result.cycleTime, cycleResult: result, cycleComparison }; } function emptyPlan( project: Project, defaults: RilsaDefaults, grenzen: AnlagenartGrenzen, intergreens: ReadonlyMap, notes: PlanNote[], ): SignalPlan { return { cycleTime: 0, cycleResult: null, cycleComparison: null, anlagenart: project.anlagenart, grenzen, phases: [], transitions: [], groups: [], intergreens, transitionTime: 0, lostTime: 0, feasible: false, notes, defaults, }; } function modulo(value: number, m: number): number { if (m <= 0) return 0; return ((value % m) + m) % m; } function formatSeconds(value: number): string { return roundTo(value, 1).toString().replace('.', ','); } /** * Drei Nachkommastellen fuer einen ungerundeten Zeitbedarf (5,833 s). Drei * und nicht zwei: Der Furtweg steht mit zwei Nachkommastellen daneben, und * 6,01 m / 1,2 m/s = 5,004 s muss als "mehr als 5 s" lesbar sein, sonst * ergibt der gedruckte Rechenweg nicht die aufgerundeten 6 s. */ function formatSecondsFine(value: number): string { return roundTo(value, 3).toFixed(3).replace('.', ','); } function formatMeters(value: number): string { return roundTo(value, 2).toString().replace('.', ','); } /** Zwei feste Nachkommastellen fuer den Furtweg (7,00 m; 6,01 m). */ function formatMetersFine(value: number): string { return roundTo(value, 2).toFixed(2).replace('.', ','); } function formatSpeed(value: number): string { return roundTo(value, 2).toString().replace('.', ','); } /** Konfliktbeziehungen als Nachschlagewerk fuer die Oberflaeche. */ export function conflictLookup(project: Project): ReadonlyMap { return new Map(project.conflicts.map((c) => [intergreenKey(c.fromId, c.toId), c])); }