import { describe, expect, it } from 'vitest'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { computeCycleTime } from '@/domain/rilsa/cycle'; import { distributeGreenTimes } from '@/domain/rilsa/greenSplit'; import { QUELLEN, type QuellenSchluessel } from '@/domain/rilsa/quellen'; import { settingFields } from '@/domain/rilsa/settings'; import type { RilsaOverrides } from '@/domain/rilsa/settings'; import { createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import type { SignalPlan } from '@/domain/plan/signalPlan'; import type { Project } from '@/domain/model/project'; /** * Nachbarfaelle zu Befund 19, behoben in Fassung 5.12.0. * * Befund 19 hat Ziel- und Hoechst-Auslastungsgrad aus der Sammelquelle * 'praxis' herausgeloest, weil deren gedruckter Hinweis - "Frei wählbar; wirkt * sich nicht auf die Einhaltung des Regelwerks aus" - fuer sie nicht zutrifft. * Der Satz steht als "Zu beachten" im Fundstellenverzeichnis der * Planunterlage. Drei Vorgabenfelder blieben an der Sammelquelle haengen; fuer * alle drei ist er nachgemessen falsch: * * A `minGreenOepnv` - geht ueber die Mindestfreigabezeit der Phase in die * Mindestumlaufzeit ein; die Umlaufzeit ist an * Regelwerksgrenzen gebunden. * B `cycleTimeStep` - `ceilToStep` laeuft vor der Kappung auf den * Hoechstwert; die Rasterung entscheidet damit allein * darueber, ob die Ueberschreitung gemeldet wird. * C `maxGreen` - kappt eine Phase, verschiebt deren Zeit auf die * uebrigen und veraendert darueber Kapazitaet, * Auslastungsgrad und Qualitaetsstufe nach HBS 2015. * * C war als "kein Treffer" gemeldet, mit der Begruendung, die * Mindestfreigabezeit gehe immer vor und der Wert gehe nicht in die * Umlaufzeit ein. Beides trifft zu und ist unten mitgemessen; der Schluss * daraus traegt nicht. * * Der letzte Fall der Datei bewacht, dass der neue Hinweis zu C nicht seinerseits * eine Meldung zusagt, die es fuer das Vorgabenfeld nicht gibt. */ const SATZ_DER_WIRKUNGSLOSIGKEIT = 'wirkt sich nicht auf die Einhaltung des Regelwerks aus'; /** Die drei Felder, um die es geht - mit der Fundstelle, die sie tragen sollen. */ const FELDER = [ { key: 'minGreenOepnv', schluessel: 'praxis-mindestfreigabezeit-oepnv' }, { key: 'maxGreen', schluessel: 'praxis-hoechstfreigabezeit' }, { key: 'cycleTimeStep', schluessel: 'praxis-umlaufzeit-rasterung' }, ] as const satisfies readonly { key: keyof RilsaOverrides; schluessel: QuellenSchluessel }[]; function feld(key: keyof RilsaOverrides) { const gefunden = settingFields().find((f) => f.key === key); expect(gefunden, `Vorgabenfeld "${key}"`).toBeDefined(); return gefunden!; } describe('Die drei letzten Felder der Sammelquelle "praxis"', () => { it('haengt keines der drei Felder mehr an der Sammelquelle', () => { for (const { key } of FELDER) { expect(feld(key).quellenSchluessel, key).not.toBe('praxis'); } }); it('gibt jedem der drei eine eigene Fundstelle - der Hinweissatz trifft nicht auf alle drei zu', () => { for (const { key, schluessel } of FELDER) { expect(feld(key).quellenSchluessel, key).toBe(schluessel); } const schluessel = new Set(FELDER.map((f) => feld(f.key).quellenSchluessel)); expect(schluessel.size, 'drei verschiedene Fundstellen').toBe(FELDER.length); }); it('druckt zu keinem der drei Felder den Satz von der Wirkungslosigkeit', () => { for (const { key } of FELDER) { expect(feld(key).quelle.hinweis ?? '', key).not.toContain(SATZ_DER_WIRKUNGSLOSIGKEIT); expect(feld(key).quelle.verwendung, key).not.toContain(SATZ_DER_WIRKUNGSLOSIGKEIT); } }); it('nennt in jeder Fundstelle, worauf der Wert wirkt', () => { // A und B wirken auf die Umlaufzeit, C auf die Bewertung nach HBS 2015. expect(feld('minGreenOepnv').quelle.verwendung).toContain('Mindestumlaufzeit'); expect(feld('cycleTimeStep').quelle.verwendung).toContain('Umlaufzeit'); expect(feld('maxGreen').quelle.verwendung).toContain('HBS 2015'); for (const { key } of FELDER) { expect(feld(key).quelle.hinweis ?? '', `${key}: Beleglage`).toContain('Kein Regelwerkswert'); } }); it('fuehrt alle drei als selbstgesetzte Schwelle, nie als gesichert', () => { for (const { key } of FELDER) { // Grundsatz: selbstgesetzte Schwellen tragen nie "gesichert". expect(feld(key).quelle.pruefstand, key).not.toBe('gesichert'); expect(feld(key).quelle.regelwerk, key).not.toContain('RiLSA'); expect(feld(key).quelle.regelwerk, key).not.toContain('HBS'); } }); it('bleibt in Kurzform bei der Planungspraxis - die Spalte des Verzeichnisses aendert sich nicht', () => { for (const { key } of FELDER) { expect(feld(key).quelle.kurz, key).toBe('Planungspraxis'); expect(feld(key).quelle.regelwerk, key).toBe('Planungspraxis'); } }); /* * Die Sammelquelle bleibt stehen - sie benennt den Massstab, an dem die drei * Trennungen gemessen wurden. Sie traegt jetzt aber kein Vorgabenfeld mehr * und erscheint damit in keinem Verzeichnis; gedruckt wird ihr Satz * nirgends. */ it('laesst den Satz an der Sammelquelle stehen, die aber kein Vorgabenfeld mehr traegt', () => { expect(QUELLEN.praxis.hinweis ?? '').toContain(SATZ_DER_WIRKUNGSLOSIGKEIT); expect(settingFields().filter((f) => f.quellenSchluessel === 'praxis')).toHaveLength(0); }); }); describe('Die Wirkung, die der Satz der Sammelquelle bestritt', () => { /* * A - die Mindestfreigabezeit des OePNV. Zweimal gemessen: einmal am * fertigen Plan, damit die Kette Vorgabe -> Phase -> Mindestumlaufzeit * belegt ist, und einmal an der Umlaufzeitrechnung selbst, damit die * Regelwerksgrenze erreicht wird. */ const DATUM = new Date('2026-01-01T00:00:00Z'); function projektMitOepnv(minGreenOepnv?: number): Project { const basis = createStandardIntersectionProject('Prüffall', DATUM); const bahn = createSignalGroup({ name: 'B1', mode: 'oepnv', armId: basis.intersection.arms[0]?.id ?? null, index: 6, }); return { ...basis, signalGroups: [...basis.signalGroups, bahn], phases: [...basis.phases, createPhase('Phase 3 - ÖPNV', [bahn.id])], settings: { ...basis.settings, rilsa: minGreenOepnv === undefined ? basis.settings.rilsa : { ...basis.settings.rilsa, minGreenOepnv }, }, }; } it('verlaengert die Mindestfreigabezeit des OePNV die Umlaufzeit des fertigen Plans', () => { expect(RILSA_DEFAULTS.minGreen.oepnv, 'Regelwert').toBe(3); const mitRegelwert = buildSignalPlan(projektMitOepnv()); const mitVorgabe = buildSignalPlan(projektMitOepnv(60)); expect(mitRegelwert.cycleTime, 'Umlaufzeit mit 3 s').toBe(65); expect(mitVorgabe.cycleTime, 'Umlaufzeit mit 60 s').toBe(80); expect(mitRegelwert.notes.map((n) => n.code)).not.toContain('mindestumlauf-massgebend'); expect(mitVorgabe.notes.map((n) => n.code)).toContain('mindestumlauf-massgebend'); }); it('treibt sie die Mindestumlaufzeit bis ueber den Hoechstwert der RiLSA', () => { // Phasenfolge mit 5 + 25 s Mindestfreigabe und 35 s Uebergangszeiten; // dazwischen die Phase des Sondersignals. const eingang = (minGreenOepnv: number) => ({ lostTime: 20, criticalFlowRatios: [0.2, 0.2], minimumCycle: 5 + 25 + minGreenOepnv + 35, step: RILSA_DEFAULTS.cycleTime.step, }); const klein = computeCycleTime('webster', eingang(3)); const gross = computeCycleTime('webster', eingang(60)); expect(klein.cycleTime, 'Umlaufzeit bei 3 s').toBe(70); expect(klein.notes.map((n) => n.code)).not.toContain('umlaufzeit-ueber-hoechstwert'); expect(gross.bounded, 'Zustand bei 60 s').toBe('maximum'); expect(gross.cycleTime).toBe(RILSA_DEFAULTS.cycleTime.max); expect(gross.notes.map((n) => n.code)).toContain('umlaufzeit-ueber-hoechstwert'); }); /* * B - die Rasterung. Derselbe rechnerische Wert, dieselbe ausgegebene * Umlaufzeit - und seit Fassung 5.13.0 auch dieselbe Beurteilung. * * GEAENDERT (Fassung 5.13.0). Dieser Fall hielt bis dahin fest, dass die * Rasterung allein ueber die Beanstandung entscheidet: bei 7 und 9 s der * Fehler 'umlaufzeit-ueber-hoechstwert', bei den uebrigen acht Rasterungen * keiner. Das war eine Messung des Zustands, nicht seine Billigung - der * Kopfkommentar der Fundstelle sagte ausdruecklich, die Reihenfolge in * cycle.ts sei damit nicht entschieden. * * Sie ist es jetzt: Beurteilt wird der erforderliche Umlauf VOR der * Rasterung. Der Fall misst weiterhin, was die Fundstelle behauptet, nur * eben den entschiedenen Zustand - die Wirkung der Rasterung auf die * Umlaufzeit selbst steht im Fall darunter. Kein Abschwaechen: Die * Messreihe ueber alle zehn Rasterungen bleibt, und die Gegenprobe mit * einem wirklich zu langen Umlauf kommt hinzu. Die ausfuehrliche Wache * dazu steht in `umlaufzeitRasterung.test.ts`. */ it('entscheidet die Rasterung nicht mehr, ob die Ueberschreitung des Hoechstwerts gemeldet wird', () => { // (1,5 * L + 5) / (1 - Y) = 119,48 s bei Y = 0,5. const Y = 0.5; const eingang = (step: number, raw = 119.48) => ({ lostTime: (raw * (1 - Y) - 5) / 1.5, criticalFlowRatios: [Y], minimumCycle: 30, step, }); const teilend = computeCycleTime('webster', eingang(5)); const nichtTeilend = computeCycleTime('webster', eingang(7)); expect(teilend.raw, 'rechnerischer Wert').toBeCloseTo(119.48, 2); expect(nichtTeilend.raw, 'derselbe rechnerische Wert').toBeCloseTo(119.48, 2); // Dieselbe Umlaufzeit - und in beiden Faellen kein Fehler im Bericht. expect(teilend.cycleTime).toBe(120); expect(nichtTeilend.cycleTime).toBe(120); expect(teilend.bounded).toBe('keine'); expect(nichtTeilend.bounded).toBe('keine'); const meldend = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter((step) => computeCycleTime('webster', eingang(step)) .notes.map((n) => n.code) .includes('umlaufzeit-ueber-hoechstwert'), ); expect(meldend, 'keine Rasterung des Feldbereichs meldet').toEqual([]); // Gegenprobe: Ein erforderlicher Umlauf ueber dem Hoechstwert wird bei // jeder Rasterung gemeldet - auch bei einer, die ihn teilt. const zuLang = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter((step) => computeCycleTime('webster', eingang(step, 121)) .notes.map((n) => n.code) .includes('umlaufzeit-ueber-hoechstwert'), ); expect(zuLang).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); it('wirkt die Rasterung weiterhin auf die Umlaufzeit selbst', () => { /* * Der Grund, aus dem dieses Feld eine eigene Fundstelle hat, bleibt * bestehen: Die Rasterung ist keine wirkungslose Anzeigegroesse. An der * Standardvorlage (rechnerisch 88,07 s) ergibt sie bei 5 s eine * Umlaufzeit von 90 s mit den Freigabezeiten 42/28 s, bei 1 s eine von * 89 s mit 42/27 s. */ const basis = createStandardIntersectionProject('Prüffall', DATUM); const mitRaster = (cycleTimeStep: number): Project => ({ ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, cycleTimeStep } }, }); const fuenf = buildSignalPlan(mitRaster(5)); const eins = buildSignalPlan(mitRaster(1)); expect(fuenf.cycleResult?.raw, 'derselbe rechnerische Wert').toBeCloseTo(88.07, 2); expect(eins.cycleResult?.raw, 'derselbe rechnerische Wert').toBeCloseTo(88.07, 2); expect(fuenf.cycleTime).toBe(90); expect(eins.cycleTime).toBe(89); expect(fuenf.phases.map((p) => p.duration)).toEqual([42, 28]); expect(eins.phases.map((p) => p.duration)).toEqual([42, 27]); }); /* * C - die Hoechstfreigabezeit. Zuerst die beiden Feststellungen, die der * gemeldete Nachbarfall richtig getroffen hat, dann der Schluss, der nicht * traegt. */ it('geht die Mindestfreigabezeit der Hoechstfreigabezeit vor', () => { const ergebnis = distributeGreenTimes( 90, [ { id: 'furt', weight: 0.4, minGreen: 25, maxGreen: 10 }, { id: 'kfz', weight: 0.4, minGreen: 5, maxGreen: 120 }, ], [10, 10], ); expect(ergebnis.phases.find((p) => p.id === 'furt')?.green, 'Furtphase').toBe(25); }); it('aendert die Hoechstfreigabezeit die Bewertung nach HBS 2015', () => { const basis = createStandardIntersectionProject('Prüffall', DATUM); const mit = (maxGreen: number): Project => ({ ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, maxGreen } }, }); expect(RILSA_DEFAULTS.maxGreen, 'Regelwert').toBe(120); const regelwert = buildSignalPlan(basis); const gekappt = buildSignalPlan(mit(10)); expect(regelwert.cycleTime, 'Umlaufzeit unveraendert').toBe(90); expect(gekappt.cycleTime, 'Umlaufzeit unveraendert').toBe(90); expect(regelwert.phases.map((p) => p.duration)).toEqual([42, 28]); /* * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0): Hier stand [60, 10]. Der * Rest, den die gekappte Hoechstfreigabezeit uebrig laesst, ging bis dahin * ganz an die Phase mit dem groessten Gewicht; jetzt wird er nach * Gewichten auf die Phasen mit Spielraum verteilt. Die Aussage dieses * Falls - die Hoechstfreigabezeit aendert die Freigabezeiten und damit die * Bewertung - traegt unveraendert, nur die Aufteilung ist eine andere. */ expect(gekappt.phases.map((p) => p.duration)).toEqual([40, 30]); /* * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0). Hier stand die Ueberlastung: * Mit gekappter Hoechstfreigabezeit fiel die zweite Phase auf 10 s, und * `leistungsfaehigkeit.ueberlastet` stand als Fehler im Bericht. Seit der * Rest nach Gewichten auf die Phasen mit Spielraum verteilt wird, bekommt * sie 30 s statt 10 s, und diese Ueberlastung entsteht nicht mehr - das * ist die Absicht der Aenderung und kein Verlust. * * PUNKT C BRAUCHT SIE AUCH NICHT. Behauptet ist, die Hoechstfreigabezeit * veraendere Kapazitaet, Auslastungsgrad und Qualitaetsstufe nach HBS * 2015; alle drei sind unmittelbar messbar und haengen an keiner Schwelle. * Das ist die staerkere Wache: Ein Fehler im Bericht setzt voraus, dass * die Wirkung gerade eine Grenze ueberschreitet, die Wirkung selbst nicht. */ const kfz = (plan: SignalPlan, name: string) => { const gruppe = plan.groups.find((g) => g.name === name); expect(gruppe, `Signalgruppe ${name}`).toBeDefined(); return gruppe!; }; expect(kfz(regelwert, 'K1').capacity?.capacity, 'Kapazität mit dem Regelwert').toBe(914); expect(kfz(gekappt, 'K1').capacity?.capacity, 'Kapazität mit 10 s').toBe(872); expect( kfz(regelwert, 'K1').capacity?.degreeOfSaturation, 'Auslastungsgrad mit dem Regelwert', ).toBeCloseTo(0.678, 3); expect(kfz(gekappt, 'K1').capacity?.degreeOfSaturation, 'Auslastungsgrad mit 10 s').toBeCloseTo( 0.711, 3, ); /* * Und die Bewertung: die mittlere Wartezeit der ersten Fussgaengerfurt, * aus der die Qualitaetsstufe folgt. * * NACHGEZOGEN MIT FASSUNG 5.43.0: Hier stand der Wechsel ihrer Stufe von B * auf C. Die Vorlage erfasst seither F1 auch gegen K4, der die Furt beim * Ausfahren quert, und rechnet den einfahrenden Fussgaenger mit te = 0 s. * F1 wartet dadurch schon mit dem Regelwert 10,8 s und steht auf C; die * gekappte Hoechstfreigabezeit verlaengert die Wartezeit weiter, * ueberschreitet aber keine Stufengrenze mehr. Gemessen wird deshalb die * Groesse, die an keiner Schwelle haengt. */ expect(kfz(regelwert, 'F1').delay?.averageDelay, 'Wartezeit mit dem Regelwert').toBe(10.8); expect(kfz(gekappt, 'F1').delay?.averageDelay, 'Wartezeit mit 10 s').toBe(11.8); }); /* * NACHTRAG (Fassung 5.12.0): Der gedruckte Hinweis dieser Fundstelle * schloss zunaechst mit dem pauschalen Satz, eine Hoechstfreigabezeit unter * der Mindestfreigabezeit sei "nicht schaltbar und wird als wirkungslos * gemeldet". Gedruckt wird er im Fundstellenverzeichnis unter dem * VORGABENFELD - und fuer das galt die zweite Haelfte nicht: phaseMaxGreen * kehrte ohne Meldung zurueck, wenn keine Signalgruppe eine kleinere * Schranke trug. Damit stand unter einem Kennwert wieder ein Satz, der ihn * falsch beschreibt: genau der Fehlertyp, gegen den diese * Datei steht. * * GEAENDERT (Fassung 5.13.0): Der Hinweis beschrieb damit einen Zustand, der * als gemeldet, aber unentschieden festgehalten war. Entschieden ist er jetzt * zugunsten der Meldung - eine wirkungslose Eingabe, von der niemand * erfaehrt, bleibt sonst stehen. Der Fall misst weiterhin beide Lagen und * bindet den Wortlaut daran; neu ist, dass die Vorgabe gemeldet WIRD. Die * ausfuehrliche Wache dazu steht in * `hoechstfreigabezeitUnterMindestfreigabezeit.test.ts`. */ it('meldet auch eine Projektvorgabe unter der Mindestfreigabezeit als wirkungslos - einmal fuer die Anlage', () => { const basis = createStandardIntersectionProject('Prüffall', DATUM); // Beide Werte liegen im Feldbereich der Vorgabenverwaltung // (Mindestfreigabezeiten bis 60 s, Hoechstfreigabezeit ab 10 s). const plan = buildSignalPlan({ ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, minGreenKfz: 40, maxGreen: 10 }, }, }); expect(plan.cycleTime, 'Umlaufzeit').toBe(100); expect( plan.phases.map((p) => p.duration), 'Freigabezeiten', ).toEqual([40, 40]); expect( plan.notes.map((n) => n.code), 'Meldungen des Plans', ).toEqual([ 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit', 'mindestumlauf-massgebend', ]); expect( plan.notes.find((n) => n.code === 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit') ?.signalGroupId, 'die Zahl steht an keiner Signalgruppe', ).toBeUndefined(); // Dieselbe Schranke je Signalgruppe eingetragen: dort haengt die Meldung // an der Gruppe. const jeGruppe = buildSignalPlan({ ...basis, signalGroups: basis.signalGroups.map((g, i) => (i === 0 ? { ...g, maxGreenOverride: 2 } : g)), }); expect(jeGruppe.notes.map((n) => n.code)).toContain( 'hoechstfreigabezeit-unter-mindestfreigabezeit', ); // Der gedruckte Hinweis der Fundstelle sagt genau diese Lage - und nicht mehr. const hinweis = feld('maxGreen').quelle.hinweis ?? ''; expect(hinweis).toContain('eine je Signalgruppe eingetragene Höchstfreigabezeit'); expect(hinweis).toContain('einmal für die Anlage'); expect(hinweis, 'der alte, unentschiedene Zustand').not.toContain('ohne Beanstandung'); }); });