import { describe, expect, it } from 'vitest'; import { computeIntergreen, regelRaeumgeschwindigkeit } from '@/domain/rilsa/intergreen'; import { RILSA_DEFAULTS } from '@/domain/rilsa/constants'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; /** * Nicht schneller raeumen, als gefahren werden darf. * * DER BEFUND KAM AUS EINEM ANGEORDNETEN PLAN, nicht aus dem Quelltext. Am * 10.09.2026 sind zwei reale, behoerdlich abgesegnete Anordnungen gegen dieses * Programm gerechnet worden: * * - Plan A: einstreifige Fuehrung, 100 m Engstelle, Raeumung mit 30 km/h, * gerechnet mit fremder Software: * **16 s**. Dieses Programm rechnete **14 s** - zwei Sekunden KUERZER als * die Unterlage, die die Behoerde abgesegnet hat. * - Plan B: 40 m Raeumweg, 50 km/h, angeordnet mit * 6 s. Dieses Programm rechnet 8 s - zwei Sekunden laenger, und das ist die * gewollte Korrektur gegenueber der Fassung 4.x. * * BEIDE ABWEICHUNGEN HABEN DIESELBE URSACHE, und sie zeigen in * entgegengesetzte Richtungen: Die fremde Software und die Fassung 4.x rechnen * mit vr = Vmax/3,6, dieses Programm mit dem festen Regelwert 10,0 m/s. Ueber * 36 km/h ist der feste Wert der kleinere und damit der sichere - das ist die * Korrektur, die 5.0 ausdruecklich eingefuehrt hat. UNTER 36 km/h kehrt sich * das um: Bei 30 km/h raeumte ein Fahrzeug rechnerisch mit 36 km/h, also * schneller, als es fahren darf, und die Zwischenzeit fiel zu kurz aus. * * DER GRUNDSATZ STAND SCHON IM PROGRAMM, nur nicht an dieser Verkehrsart: * `SAFETY_FLOORS.maxClearingSpeedOepnv` traegt den Satz "nicht schneller * raeumen, als gefahren werden darf" im Kommentar, und fuer den OePNV wird er * mit `Math.min(..., vZul / 3.6)` auch angewandt. Fuer Kraftfahrzeuge galt * allein der feste Deckel von 10,0 m/s, ohne Bezug zur Vmax des Stroms. * * WAS DIESE DATEI BEWACHT: dass der Deckel gilt, dass er nur nach unten wirkt, * dass er die Einfahrgeschwindigkeit NICHT anfasst und dass er sich meldet. */ const D = RILSA_DEFAULTS; function raeumt(teil: Partial = {}): ClearingStream { return { mode: 'kfz', movement: 'geradeaus', clearingDistance: 100, vehicleClass: 'pkw', vZul: 30, ...teil, }; } function faehrtEin(teil: Partial = {}): EnteringStream { return { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0, ...teil }; } describe('Der Deckel an der zulaessigen Hoechstgeschwindigkeit', () => { it('rechnet den angeordneten Plan A nach', () => { /* * DER FALL, DER DEN BEFUND AUSGELOEST HAT. 100 m Engstelle, 30 km/h. * * Rechenweg mit dem Deckel: sr = 100 m + 6 m Laengenzuschlag des Pkw = * 106 m; vr = 30 / 3,6 = 8,333 m/s; tr = 12,72 s; te = 0 s (der * Gegenverkehr steht an der Haltlinie); tue = 3 s (Gelbzeit bei 30 km/h). * tz = 15,72 s, aufgerundet 16 s - genau die angeordnete Zahl. * * Ohne den Deckel waeren es 10,6 s Raeumzeit und 14 s Zwischenzeit. */ const r = computeIntergreen(raeumt(), faehrtEin(), D); expect(r.clearingSpeed, 'geraeumt wird mit der Vmax des Stroms').toBeCloseTo(30 / 3.6, 6); expect(r.intergreen, 'die angeordnete Zwischenzeit').toBe(16); }); it('laesst den Regelwert stehen, wo er ohnehin der kleinere ist', () => { /* * Die Gegenprobe zur Fassung 4.x. Bei 50 km/h waere Vmax/3,6 = 13,9 m/s - * deutlich ueber dem Regelwert. Der Deckel darf dort NICHTS tun, sonst * naehme er die Korrektur zurueck, die 5.0 eingefuehrt hat: Der * angeordnete Plan B traegt 6 s, dieses Programm rechnet 8 s, und * das soll so bleiben. */ const r = computeIntergreen(raeumt({ clearingDistance: 40, vZul: 50 }), faehrtEin(), D); expect(r.clearingSpeed, 'der Regelwert gilt weiter').toBe(D.clearingSpeed.kfzStraight); expect(r.intergreen).toBe(8); }); it('greift genau unterhalb von 36 km/h', () => { // 10,0 m/s entsprechen 36 km/h. Darueber ist der Regelwert der kleinere, // darunter die Vmax - die Grenze liegt also dort, wo beide gleich sind. expect(regelRaeumgeschwindigkeit({ mode: 'kfz', movement: 'geradeaus', vZul: 36 }, D)).toBe(10); expect( regelRaeumgeschwindigkeit({ mode: 'kfz', movement: 'geradeaus', vZul: 35 }, D), ).toBeCloseTo(35 / 3.6, 6); expect(regelRaeumgeschwindigkeit({ mode: 'kfz', movement: 'geradeaus', vZul: 40 }, D)).toBe(10); }); it('ruehrt den abbiegenden Strom nicht an, der ohnehin langsamer raeumt', () => { // 7,0 m/s liegen unter 8,33 m/s; der Deckel darf nicht nach OBEN wirken. expect(regelRaeumgeschwindigkeit({ mode: 'kfz', movement: 'links', vZul: 30 }, D)).toBe( D.clearingSpeed.kfzTurning, ); }); it('ruehrt Fussgaenger und Radverkehr nicht an', () => { /* * Ihre Raeumgeschwindigkeit hat mit der zulaessigen Hoechstgeschwindigkeit * des Fahrverkehrs nichts zu tun: Ein Fussgaenger geht nicht schneller, * weil dort 50 gefahren werden darf, und nicht langsamer, weil dort 30 * gilt. * * GEMESSEN AN EINER MUTATION: Mit 30 km/h bewies dieser Fall gar nichts - * der Deckel laege bei 8,33 m/s, und 1,2 bzw. 4,0 m/s liegen ohnehin * darunter. Wer die Verkehrsartabfrage aus `vmaxDeckel` entfernte, blieb * gruen. Geprueft wird deshalb bei Schrittgeschwindigkeit: 4 km/h ergaeben * einen Deckel von 1,11 m/s, und der laege UNTER der Gehgeschwindigkeit. * Zeichen 325 gibt es wirklich; hier steht es fuer den Grenzfall, an dem * sich die Abfrage ueberhaupt erst zeigt. */ expect(regelRaeumgeschwindigkeit({ mode: 'fuss', movement: 'querung', vZul: 4 }, D)).toBe( D.clearingSpeed.fuss, ); expect(regelRaeumgeschwindigkeit({ mode: 'rad', movement: 'querung', vZul: 4 }, D)).toBe( D.clearingSpeed.rad, ); // Und beim gewoehnlichen Tempo 30 selbstverstaendlich auch. expect(regelRaeumgeschwindigkeit({ mode: 'fuss', movement: 'querung', vZul: 30 }, D)).toBe( D.clearingSpeed.fuss, ); }); it('faellt ohne eingetragene Vmax auf den Regelwert zurueck', () => { // Ohne Vmax gibt es keinen Deckel. Der Regelwert ist dann das Beste, was // das Programm weiss - und er ist konservativer als jede Annahme. expect(regelRaeumgeschwindigkeit({ mode: 'kfz', movement: 'geradeaus', vZul: 0 }, D)).toBe( D.clearingSpeed.kfzStraight, ); }); it('deckelt die EINFAHRgeschwindigkeit ausdruecklich nicht', () => { /* * DIE SCHRANKE LIEGT DORT UNTEN, NICHT OBEN, und wer die Symmetrie * herstellen wollte, kehrte die Sicherheitsrichtung um: In * tz = tue + tr − te wird die Einfahrzeit ABGEZOGEN. Eine niedrigere * Einfahrgeschwindigkeit verlaengert te und VERKUERZT damit die * Zwischenzeit. Bei 30 km/h bleibt es deshalb bei den 11,1 m/s. */ const r = computeIntergreen( raeumt({ clearingDistance: 20 }), faehrtEin({ enteringDistance: 10 }), D, ); expect(r.enteringSpeed, 'die Einfahrgeschwindigkeit ist gedeckelt worden').toBe( D.enteringSpeed.kfz, ); }); it('sagt im Rechenweg, dass gedeckelt wurde', () => { /* * Eine stille Ersetzung waere hier besonders schlecht: Das * Kennwerteverzeichnis der Planunterlage nennt fuer den geradeaus * fahrenden Kfz-Strom 10,0 m/s, im Rechenweg derselben Unterlage stuenden * 8,33 m/s, und nichts erklaerte den Unterschied. */ const r = computeIntergreen(raeumt(), faehrtEin(), D); const notiz = r.notes.find((n) => n.code === 'raeumgeschwindigkeit-auf-vmax-gedeckelt'); expect(notiz, 'die Kappung meldet sich nicht').toBeDefined(); expect(notiz?.severity, 'das ist der Regelfall und kein Fehler').toBe('hinweis'); expect(notiz?.message, 'der Regelwert fehlt in der Meldung').toContain('10 m/s'); expect(notiz?.message, 'der angesetzte Wert fehlt in der Meldung').toContain('8,33 m/s'); expect(notiz?.message, 'die Vmax fehlt in der Meldung').toContain('30 km/h'); }); it('meldet dabei keinen Fehler ueber die Vorgaben', () => { /* * Die Kappung darf nicht in die Meldung "Räumgeschwindigkeit aus den * Vorgaben über der Obergrenze" laufen. Die waere doppelt falsch: Der Wert * kommt nicht aus den Vorgaben, sondern aus dem Regelwerk, und er ist kein * Fehler, sondern der Regelfall an jeder Anlage mit Tempo 30. */ const r = computeIntergreen(raeumt(), faehrtEin(), D); expect(r.notes.filter((n) => n.severity === 'fehler')).toEqual([]); expect(r.notes.map((n) => n.code)).not.toContain('raeumgeschwindigkeit-vorgabe-zu-hoch'); }); it('steht die Kappung einmal je Projekt im Pruefbericht', () => { /* * DER RECHENWEG ALLEIN GENUEGT NICHT. Die Hinweise aus * `computeIntergreen` verwirft der Planaufbau, bevor sie in `plan.notes` * gelangen - zu Recht, denn an einer Anlage mit Tempo 30 traefe die * Meldung jede einzelne Beziehung. Gedruckt wird der Pruefbericht aber * aus `plan.notes` und den Regeln; ohne diesen Befund stuende im * Kennwertnachweis der Unterlage 10,0 m/s und im Rechenweg daneben ein * kleinerer Wert, ohne ein Wort dazu. */ /* * AM KNOTENPUNKT GEMESSEN (nachgezogen mit 5.27.0): Bis dahin stand hier * eine einstreifige Verkehrsfuehrung mit Tempo 30. Die rechnet das * Programm seit 5.27.0 nach RiLSA 2015, Abschnitt 5.2.2, und dort kommt * die Raeumgeschwindigkeit aus der eigenen Staffel des Abschnitts - der * Deckel aus 5.22.0 greift dann nicht mehr, weil es nichts zu deckeln * gibt. Er gilt unveraendert am Knotenpunkt und an der * Fussgaengerschutzanlage, und genau dort wird er jetzt gemessen. Fuer die * Engstelle hat Abschnitt 5.2 einen eigenen Deckel bekommen (siehe * `engstellenRaeumgeschwindigkeit`); er greift erst unter 30 km/h. */ const anlage = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'kreuzung', vZul: 30 }, 'Tempo 30', new Date('2025-03-11T12:00:00Z'), ); const plan = buildSignalPlan(anlage); const bericht = validateProject(anlage, plan, new Date('2025-03-11T12:00:00Z')); const befunde = bericht.findings.filter( (f) => f.rule === 'zwischenzeiten.raeumgeschwindigkeit-auf-vmax-gedeckelt', ); expect(befunde, 'genau ein Befund, nicht einer je Beziehung').toHaveLength(1); expect(befunde[0]?.severity).toBe('hinweis'); expect(befunde[0]?.message, 'die betroffenen Gruppen fehlen').toContain('"K1"'); expect(befunde[0]?.message, 'die betroffenen Gruppen fehlen').toContain('"K2"'); // Und bei 50 km/h steht er nicht da. const schnell = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einstreifig', vZul: 50, engstellenlaenge: 100 }, 'Vergleich', new Date('2025-03-11T12:00:00Z'), ); const planSchnell = buildSignalPlan(schnell); expect( validateProject(schnell, planSchnell, new Date('2025-03-11T12:00:00Z')).findings.map( (f) => f.rule, ), 'bei 50 km/h wird nichts gedeckelt', ).not.toContain('zwischenzeiten.raeumgeschwindigkeit-auf-vmax-gedeckelt'); }); it('laesst eine ueberhoehte Vorgabe weiter auffallen', () => { /* * Die Gegenrichtung: Ein Kennwertsatz mit 12 m/s liegt sowohl ueber der * harten Schranke von 10 m/s als auch ueber der Vmax. Beides sind eigene * Sachverhalte, und der Fehler zur Vorgabe darf nicht dadurch verschwinden, * dass der Deckel schon gegriffen hat. */ const ueberhoeht = { ...D, clearingSpeed: { ...D.clearingSpeed, kfzStraight: 12 }, }; const r = computeIntergreen(raeumt(), faehrtEin(), ueberhoeht); expect(r.notes.map((n) => n.code)).toContain('raeumgeschwindigkeit-vorgabe-zu-hoch'); expect(r.clearingSpeed, 'am Ende gilt trotzdem die Vmax').toBeCloseTo(30 / 3.6, 6); }); });