import { describe, expect, it } from 'vitest'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { OEPNV_ANFAHRBESCHLEUNIGUNG, RILSA_DEFAULTS, SAFETY_FLOORS, fahrzeugartBeschriftung, type RilsaDefaults, } from '@/domain/rilsa/constants'; import { resolveRilsaSettings, type RilsaOverrides } from '@/domain/rilsa/settings'; import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types'; import { parseProject } from '@/domain/model/schema'; import { createConflict, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { CURRENT_SCHEMA_VERSION, type Project } from '@/domain/model/project'; /** * Befund A2: OePNV-Zwischenzeitfall nach RiLSA 2015. * * Primaer belegt durch das FGSV-Aenderungsblatt vom 29.07.2015, das die * Originalseiten 23-25 der RiLSA 2015 wiedergibt: * * Fall 3 (ohne Halt): "Die Ueberfahrzeit betraegt bei Strassenbahnen: * tue = 3 s bei Vmax <= 30 km/h, tue = 5 s bei 30 km/h < Vmax <= 50 km/h, * tue = 7 s bei 50 km/h < Vmax <= 70 km/h." - "vr = Vmax/3,6" - * "lFZ = 15 m bei Strassenbahnen, lFZ = 6 m bei Linienbussen". * * Fall 4 (bei Halt vor dem Knotenpunkt): "Ueberfahrzeit: tue = 0 s"; * Anfahrbeschleunigung Strassenbahn a = 1,0 m/s2, Linienbus a = 1,2 m/s2; * tue + tr = Wurzel aus (2*(s0+lFZ)/a), bei Erreichen von Vmax Restweg mit * Vmax/3,6. * * Der Altbestand rechnete fest mit Ueberfahrzeit 0 s und vr = 10 m/s - die * Zwischenzeit einer Bahn ohne Halt (Vmax 50, Raeumweg + Laenge 40 m) war * damit rund 4 s zu kurz. */ const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); function bahnRaeumend(extra: Partial = {}): ClearingStream { return { mode: 'oepnv', movement: 'geradeaus', // 25 m Grundraeumweg + 15 m Fahrzeuglaenge = 40 m Raeumweg. clearingDistance: 25, vehicleClass: 'strassenbahn', vZul: 50, ...extra, }; } function kfzEinfahrend(extra: Partial = {}): EnteringStream { return { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8, ...extra }; } describe('OePNV-Ueberfahrzeit: Vmax-Staffel (RiLSA Fall 3)', () => { it('staffelt 3/5/7 s an den Grenzen 30, 50 und 70 km/h', () => { const tue = (vZul: number): number => computeIntergreen(bahnRaeumend({ vZul }), kfzEinfahrend()).crossingTime; expect(tue(20)).toBe(3); expect(tue(30)).toBe(3); // "tue = 3 s bei Vmax <= 30 km/h" - einschliesslich expect(tue(31)).toBe(5); // "30 km/h < Vmax" expect(tue(50)).toBe(5); expect(tue(51)).toBe(7); expect(tue(70)).toBe(7); }); it('rechnet das Zahlenbeispiel zu Befund A2: Bahn ohne Halt, Vmax 50, 40 m Raeumweg', () => { // Befund A2: vor der Fassung 5.4.0 rechnete das Programm 0 + 40/10 = 4,0 s, // die RiLSA 5 + 40/13,9 = 7,9 s (vor Einfahrzeitabzug, deshalb Einfahrweg 0). const result = computeIntergreen(bahnRaeumend(), kfzEinfahrend({ enteringDistance: 0 })); expect(result.vehicleLength).toBe(15); expect(result.clearingPath).toBe(40); expect(result.crossingTime).toBe(5); expect(result.clearingSpeed).toBeCloseTo(50 / 3.6, 6); expect(result.clearingTime).toBeCloseTo(2.88, 3); expect(result.raw).toBeCloseTo(7.88, 3); expect(result.intergreen).toBe(8); expect(result.raeumansatz).toBe('geschwindigkeit'); expect(result.anfahrbeschleunigung).toBeNull(); }); }); describe('OePNV-Raeumgeschwindigkeit: Vmax/3,6 mit Schranken', () => { it('raeumt mit Vmax/3,6 - auch oberhalb der alten Kfz-Schranke von 10 m/s', () => { const result = computeIntergreen(bahnRaeumend({ vZul: 70 }), kfzEinfahrend()); expect(result.clearingSpeed).toBeCloseTo(70 / 3.6, 6); expect(result.notes.some((n) => n.severity === 'fehler')).toBe(false); }); it('faellt ohne Vmax konservativ auf den Rueckfallwert zurueck und warnt', () => { const result = computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend()); expect(result.clearingSpeed).toBe(RILSA_DEFAULTS.clearingSpeed.oepnv); expect(result.crossingTime).toBe(7); // oberste Staffelstufe, sichere Seite expect( result.notes.some((n) => n.code === 'oepnv-vmax-fehlt' && n.severity === 'warnung'), ).toBe(true); }); it('nimmt ohne Vmax den KLEINEREN von Rueckfallwert und bisherigem Verhalten', () => { // Ein niedrigerer konfigurierter Rueckfallwert bleibt stehen ... const niedrig: RilsaDefaults = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, oepnv: 8 }, }; expect( computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend(), niedrig).clearingSpeed, ).toBe(8); // ... ein hoeherer wird auf die bisherigen 10 m/s gekappt (mit Fehler, // siehe raeumgeschwindigkeitSchranken.test.ts). const hoch: RilsaDefaults = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, oepnv: 15 }, }; expect(computeIntergreen(bahnRaeumend({ vZul: 0 }), kfzEinfahrend(), hoch).clearingSpeed).toBe( SAFETY_FLOORS.maxClearingSpeedKfz, ); }); it('begrenzt bei Vmax ueber 70 km/h auf die OePNV-Obergrenze und warnt', () => { // VwV-StVO zu Par. 37 Rn. 10: oberhalb 70 km/h sollen LSA nicht betrieben // werden - die Staffel der RiLSA endet dort aus demselben Grund. const result = computeIntergreen(bahnRaeumend({ vZul: 100 }), kfzEinfahrend()); expect(result.clearingSpeed).toBeCloseTo(SAFETY_FLOORS.maxClearingSpeedOepnv, 6); expect(result.crossingTime).toBe(7); expect( result.notes.some((n) => n.code === 'oepnv-vmax-ueber-70' && n.severity === 'warnung'), ).toBe(true); }); it('deckelt eine Ueberschreibung gegen die Vmax des Stroms - nicht schneller raeumen als erlaubt faehrt', () => { // 15 m/s = 54 km/h. Bei Vmax 50 unzulaessig ... const bei50 = computeIntergreen( bahnRaeumend({ vZul: 50, clearingSpeedOverride: 15 }), kfzEinfahrend(), ); expect(bei50.clearingSpeed).toBeCloseTo(50 / 3.6, 6); expect( bei50.notes.some((n) => n.code === 'raeumgeschwindigkeit-zu-hoch' && n.severity === 'fehler'), ).toBe(true); // ... bei Vmax 70 zulaessig (unterhalb 70/3,6 = 19,4 m/s). const bei70 = computeIntergreen( bahnRaeumend({ vZul: 70, clearingSpeedOverride: 15 }), kfzEinfahrend(), ); expect(bei70.clearingSpeed).toBe(15); expect(bei70.notes.some((n) => n.severity === 'fehler')).toBe(false); // Die absolute Obergrenze gilt auch bei Vmax 70. const zuHoch = computeIntergreen( bahnRaeumend({ vZul: 70, clearingSpeedOverride: 25 }), kfzEinfahrend(), ); expect(zuHoch.clearingSpeed).toBeCloseTo(SAFETY_FLOORS.maxClearingSpeedOepnv, 6); expect( zuHoch.notes.some( (n) => n.code === 'raeumgeschwindigkeit-zu-hoch' && n.severity === 'fehler', ), ).toBe(true); }); }); describe('OePNV-Haltefall: Anfahransatz (RiLSA Fall 4)', () => { it('rechnet die Strassenbahn mit tue = 0 s und tr = Wurzel(2*sr/a), a = 1,0 m/s2', () => { const result = computeIntergreen(bahnRaeumend({ haltVorKnoten: true }), kfzEinfahrend()); expect(result.crossingTime).toBe(0); expect(result.raeumansatz).toBe('anfahren'); expect(result.anfahrbeschleunigung).toBe(OEPNV_ANFAHRBESCHLEUNIGUNG.strassenbahn); // sr = 40 m, a = 1,0: Wurzel(2*40/1,0) = 8,944 s. Vmax 50 km/h wird auf // den 40 m nicht erreicht (Beschleunigungsweg 13,89^2/2 = 96,5 m). expect(result.clearingTime).toBeCloseTo(Math.sqrt(80), 3); // Zwischenzeit bleibt aus denselben Groessen nachrechenbar: // 0 + 8,944 - 8/11,1 = 8,224 -> 9 s. expect(result.raw).toBeCloseTo(8.944 - 8 / 11.1, 2); expect(result.intergreen).toBe(9); }); it('rechnet den Linienbus mit a = 1,2 m/s2', () => { const result = computeIntergreen( bahnRaeumend({ haltVorKnoten: true, vehicleClass: 'bus' }), kfzEinfahrend(), ); // Laengenzuschlag Linienbus 6 m (RiLSA-Rechenlaenge, Fall 3 woertlich; // seit Befund B5 auch der Vorgabewert). sr = 25 + 6 = 31 m; // Wurzel(2*31/1,2) = 7,188 s. const srBus = 25 + RILSA_DEFAULTS.vehicleLength.bus; expect(RILSA_DEFAULTS.vehicleLength.bus).toBe(6); expect(result.anfahrbeschleunigung).toBe(OEPNV_ANFAHRBESCHLEUNIGUNG.bus); expect(result.clearingPath).toBe(srBus); expect(result.clearingTime).toBeCloseTo(Math.sqrt((2 * srBus) / 1.2), 3); }); it('faehrt nach Erreichen von Vmax den Restweg mit Vmax/3,6', () => { // Vmax 30 km/h = 8,333 m/s; Beschleunigungsweg 8,333^2/(2*1,0) = 34,72 m. // sr = 85 + 15 = 100 m > 34,72 m -> zweiter Zweig der Formel: // tr = 8,333/1,0 + (100 - 34,72)/8,333 = 16,167 s. const result = computeIntergreen( bahnRaeumend({ haltVorKnoten: true, vZul: 30, clearingDistance: 85 }), kfzEinfahrend(), ); const v = 30 / 3.6; const erwartet = v / 1.0 + (100 - (v * v) / 2) / v; expect(result.clearingTime).toBeCloseTo(erwartet, 3); // Der Deckel wirkt: Die reine Wurzelformel ergaebe nur 14,14 s. expect(result.clearingTime).toBeGreaterThan(Math.sqrt(200)); }); it('laesst den Halt bei anderen Verkehrsarten wirkungslos', () => { const mitHalt = computeIntergreen( { mode: 'kfz', movement: 'geradeaus', clearingDistance: 22, vehicleClass: 'pkw', vZul: 50, haltVorKnoten: true, }, kfzEinfahrend(), ); expect(mitHalt.raeumansatz).toBe('geschwindigkeit'); expect(mitHalt.crossingTime).toBe(3); }); }); describe('Fahrzeugart Strassenbahn', () => { it('setzt die RiLSA-Rechenlaenge 15 m an', () => { expect(RILSA_DEFAULTS.vehicleLength.strassenbahn).toBe(15); const result = computeIntergreen(bahnRaeumend(), kfzEinfahrend()); expect(result.vehicleLength).toBe(15); expect(result.clearingPath).toBe(40); }); it('beschriftet mit echtem Umlaut und dem tatsaechlichen Zuschlag', () => { expect(fahrzeugartBeschriftung('strassenbahn')).toBe('Straßenbahn (15 m)'); }); it('laesst den Laengenzuschlag nicht unter die Rechenlaenge sinken', () => { const { defaults, notes } = resolveRilsaSettings({ vehicleLengthStrassenbahn: 10 }); expect(defaults.vehicleLength.strassenbahn).toBe(SAFETY_FLOORS.minVehicleLengthStrassenbahn); expect( notes.some( (n) => n.code === 'vorgabe-unterschritten:vehicleLengthStrassenbahn' && n.severity === 'fehler', ), ).toBe(true); }); it('nimmt eine begruendete oertliche Verlaengerung an', () => { const { defaults, deviations } = resolveRilsaSettings({ vehicleLengthStrassenbahn: 30 }); expect(defaults.vehicleLength.strassenbahn).toBe(30); expect(deviations.some((d) => d.key === 'vehicleLengthStrassenbahn')).toBe(true); }); }); describe('Veraltete Vorgabe "Ueberfahrzeit OePNV"', () => { it('wird gemeldet statt stillschweigend verworfen', () => { // Dateien bis Fassung 5.3.5 koennen den Schluessel tragen; er hat keine // Wirkung mehr, weil die Ueberfahrzeit der Vmax-Staffel folgt. const alt = { crossingTimeOepnv: 2 } as unknown as RilsaOverrides; const { notes } = resolveRilsaSettings(alt); expect( notes.some( (n) => n.code === 'vorgabe-veraltet:crossingTimeOepnv' && n.severity === 'hinweis', ), ).toBe(true); }); }); describe('Projektdatei: strassenbahn und haltVorKnoten ueberleben den Hin- und Rueckweg', () => { function projektMitBahn(): Project { const base = createStandardIntersectionProject('ÖPNV-Rundreise', FIXED_DATE); const bahn = { ...createSignalGroup({ name: 'B1', mode: 'oepnv', index: 6 }), id: 'sg-bahn' }; const ersteGruppe = base.signalGroups[0]!; return { ...base, signalGroups: [...base.signalGroups, { ...bahn, vehicleClass: 'strassenbahn', vZul: 50 }], conflicts: [ ...base.conflicts, { ...createConflict('sg-bahn', ersteGruppe.id), id: 'cf-bahn', clearingDistance: 25, enteringDistance: 8, haltVorKnoten: true, }, ], }; } it('traegt beide Angaben unveraendert durch Datei und Einlesen', () => { const original = projektMitBahn(); const { project, issues } = parseProject(JSON.parse(JSON.stringify(original)), FIXED_DATE); // Seit Befund C1 (blindenzusatz) steht das Schema auf 11; hier zaehlt nur, // dass die Datei den aktuellen Stand traegt. expect(project.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); expect(project.signalGroups.find((g) => g.id === 'sg-bahn')?.vehicleClass).toBe('strassenbahn'); expect(project.conflicts.find((c) => c.id === 'cf-bahn')?.haltVorKnoten).toBe(true); // Keine Reparaturmeldung: 'strassenbahn' ist eine bekannte Fahrzeugart und // darf beim Einlesen nicht auf 'pkw' zurueckfallen. expect(issues.filter((i) => i.severity !== 'hinweis')).toEqual([]); }); it('erfindet fuer Bestandsbeziehungen keinen Haltvermerk', () => { // Eine Datei ohne das Feld sagt "ohne Halt"; beim Einlesen darf daraus // keine Angabe werden, die niemand gemacht hat. const original = projektMitBahn(); const { project } = parseProject(JSON.parse(JSON.stringify(original)), FIXED_DATE); const alteBeziehung = project.conflicts.find((c) => c.id !== 'cf-bahn'); expect(alteBeziehung).toBeDefined(); expect('haltVorKnoten' in (alteBeziehung as object)).toBe(false); }); });