import { describe, expect, it } from 'vitest'; import { abflusszeit, computeCapacity, saturationFlow } from '@/domain/rilsa/capacity'; import { computeDelay, computeFussRadDelay, hbsReststau, kfzServiceLevel, serviceLevelFor, sperrzeitenAusFenstern, } from '@/domain/rilsa/delay'; import { RILSA_DEFAULTS, SERVICE_LEVEL_SCALES } from '@/domain/rilsa/constants'; /* * Befunde B1 bis B4. * * B2: Die Wartezeit wurde fuer beide Einstellungen nach der HCM-Formel * gerechnet, waehrend der Ausdruck "Bewertung nach HBS 2015" behauptete. * Jetzt rechnet die Einstellung HBS das Verfahren des HBS 2015 (Leyn 2016, * Gl. 2-7 bis 2-10 und 3-2). Als Referenz dient ein LISA+-Ausdruck * "HBS-Bewertung 2015" (Kornwestheim, 17.01.2017, tU = 90 s, T = 3600 s), * dessen Zeilen sich mit der Formelkette nachrechnen lassen: * * SG tF tA fA q qS C x tW NGE QSV * K5 10 11 0,122 167 1992 243 0,687 59,019 1,428 D * K7 6 7 0,078 101 1986 155 0,652 67,291 1,162 D * K2/M 7 8 0,089 88 1949 173 0,509 52,000 0,619 D * * LISA+ rundet die Kapazitaet auf ganze Kfz/h und bildet x = q / C_gerundet; * die Rueckstauformel trifft mit dem Vorgabewert des Instationaritaetsfaktors * fin = 1,1 (FGSV-Dialog zum HBS 2015, Kapitel S4). */ const LISA_REFERENZ = [ { name: 'K5', tF: 10, tA: 11, q: 167, qS: 1992, C: 243, x: 0.687, tW: 59.019, NGE: 1.428, qsv: 'D', }, { name: 'K7', tF: 6, tA: 7, q: 101, qS: 1986, C: 155, x: 0.652, tW: 67.291, NGE: 1.162, qsv: 'D', }, { name: 'K2/M', tF: 7, tA: 8, q: 88, qS: 1949, C: 173, x: 0.509, tW: 52.0, NGE: 0.619, qsv: 'D' }, ] as const; describe('HBS-2015-Wartezeitverfahren (Befund B2)', () => { it('rechnet die Abflusszeit tA = tF + 1 s je Freigabezeitfenster, gedeckelt auf tU', () => { expect(abflusszeit(10, 90, { verfahren: 'HBS' })).toBe(11); expect(abflusszeit(10, 90, { verfahren: 'HBS', freigabefenster: 2 })).toBe(12); expect(abflusszeit(89.5, 90, { verfahren: 'HBS' })).toBe(90); expect(abflusszeit(0, 90, { verfahren: 'HBS' })).toBe(0); // HCM: die Freigabezeit selbst. expect(abflusszeit(10, 90, { verfahren: 'HCM' })).toBe(10); }); it('trifft Kapazitaet, Auslastungsgrad, Rueckstau und Wartezeit des LISA+-Ausdrucks', () => { for (const zeile of LISA_REFERENZ) { const kapazitaet = computeCapacity(zeile.tF, 90, zeile.qS, zeile.q, { verfahren: 'HBS' }); expect(kapazitaet.abflusszeit, `${zeile.name}: tA`).toBe(zeile.tA); // K5 und K2/M treffen exakt. K7: 1986 * 7 / 90 = 154,47 -> 154, LISA+ // druckt 155; mit dem gedruckten (gerundeten) qS ist das nicht // reproduzierbar, die Ursache bleibt offen (Toleranz 1 Kfz/h). Auf die // Wartezeitkette hat es keinen Einfluss - sie wird unten mit dem // LISA+-C nachgerechnet. expect(Math.abs(kapazitaet.capacity - zeile.C), `${zeile.name}: C`).toBeLessThanOrEqual(1); expect(kapazitaet.degreeOfSaturation, `${zeile.name}: x`).toBeCloseTo(zeile.x, 2); // Die Wartezeitkette wird mit der von LISA+ ausgewiesenen Kapazitaet // nachgerechnet - so, wie ein Pruefer es mit dem Ausdruck taete. const wartezeit = computeDelay({ cycleTime: 90, greenTime: zeile.tF, capacity: zeile.C, demand: zeile.q, verfahren: 'HBS', mode: 'kfz', abflusszeit: kapazitaet.abflusszeit, }); expect(wartezeit.rechenweg.instationaritaetsfaktor).toBe(1.1); expect(wartezeit.rechenweg.betrachtungszeitraum).toBe(1); expect(wartezeit.rechenweg.reststau, `${zeile.name}: NGE`).toBeCloseTo(zeile.NGE, 2); // Auf eine Zehntelsekunde: LISA+ druckt drei Nachkommastellen aus // ungerundeten Zwischenwerten, das Programm rundet C und tW. expect(Math.abs(wartezeit.averageDelay - zeile.tW), `${zeile.name}: tW`).toBeLessThanOrEqual( 0.1, ); expect(wartezeit.serviceLevel?.grade, `${zeile.name}: QSV`).toBe(zeile.qsv); expect(wartezeit.serviceLevel?.scale).toBe('HBS'); expect(wartezeit.serviceLevel?.kriterium).toBe('mittlere-wartezeit'); } }); it('bildet den Rueckstau als groesseren von Instationaritaets- und Akcelik-Term', () => { // Bei fin = 1 gewinnt der Akcelik-Term (T*C statt 0,58*T*C). const akcelik = (x: number, C: number): number => (C / 4) * (x - 1 + Math.sqrt((x - 1) ** 2 + (4 * x) / C)); expect(hbsReststau(0.8, 600, 1, 1)).toBeCloseTo(akcelik(0.8, 600), 9); // Mit fin > 1 kann der erste Term ueberwiegen - er ist nie kleiner als 0. expect(hbsReststau(0.9, 600, 1, 1.3)).toBeGreaterThan(akcelik(0.9, 600)); expect(hbsReststau(0.1, 600, 1, 1.1)).toBeGreaterThanOrEqual(0); }); it('liefert bei hoher Auslastung deutlich laengere Wartezeiten als das HCM-Verfahren', () => { // Die Kernaussage von Befund B2: Die HCM-Formel (T = 0,25 h) unterschaetzt // die Wartezeit im Bereich x ab etwa 0,9 - genau dort, wo die Stufe kippt. const gemeinsam = { cycleTime: 90, greenTime: 30, demand: 620, mode: 'kfz' as const }; const hbsKap = computeCapacity(30, 90, 2000, 620, { verfahren: 'HBS' }); const hcmKap = computeCapacity(30, 90, 2000, 620, { verfahren: 'HCM' }); const hbs = computeDelay({ ...gemeinsam, capacity: hbsKap.capacity, verfahren: 'HBS', abflusszeit: hbsKap.abflusszeit, }); const hcm = computeDelay({ ...gemeinsam, capacity: hcmKap.capacity, verfahren: 'HCM', abflusszeit: hcmKap.abflusszeit, }); expect(hcm.degreeOfSaturation).toBeGreaterThan(0.9); expect(hbs.averageDelay).toBeGreaterThan(hcm.averageDelay * 1.2); expect(hbs.verfahren).toBe('HBS'); expect(hcm.verfahren).toBe('HCM'); expect(hcm.rechenweg.betrachtungszeitraum).toBe(0.25); expect(hcm.rechenweg.hcmK).toBe(0.5); }); it('haelt die Anteile konsistent: tW = tW,G + tW,R', () => { const wartezeit = computeDelay({ cycleTime: 90, greenTime: 30, capacity: 689, demand: 400, verfahren: 'HBS', mode: 'kfz', }); expect(wartezeit.averageDelay).toBeCloseTo( wartezeit.uniformDelay + wartezeit.incrementalDelay, 0, ); // Ohne uebergebene Abflusszeit gilt tF + 1 s. expect(wartezeit.rechenweg.abflusszeit).toBe(31); }); it('laesst die Anteile auf den ausgegebenen Ziffern aufgehen: tW = tW,G + tW,R', () => { // Unabhaengig gerundet ergaebe K5 "37,9 + 21,2 = 59,0" - eine Zeile, die // sich selbst widerlegt. Der Reststauanteil ist deshalb die Differenz der // ausgegebenen Werte. for (const zeile of LISA_REFERENZ) { const w = computeDelay({ cycleTime: 90, greenTime: zeile.tF, capacity: zeile.C, demand: zeile.q, verfahren: 'HBS', mode: 'kfz', abflusszeit: zeile.tA, }); expect(Math.round((w.uniformDelay + w.incrementalDelay) * 10)).toBe( Math.round(w.averageDelay * 10), ); } const k5 = computeDelay({ cycleTime: 90, greenTime: 10, capacity: 243, demand: 167, verfahren: 'HBS', mode: 'kfz', abflusszeit: 11, }); expect(k5.uniformDelay).toBe(37.9); expect(k5.incrementalDelay).toBe(21.1); expect(k5.averageDelay).toBe(59); }); it('vergibt ohne Nachfrage und ohne Kapazitaet keine Stufe (nichts zu bewerten)', () => { const leer = computeDelay({ cycleTime: 90, greenTime: 0, capacity: 0, demand: 0, verfahren: 'HBS', mode: 'kfz', }); expect(leer.serviceLevel).toBeNull(); expect(leer.degreeOfSaturation).toBe(0); // ... aber mit Nachfrage ist C = 0 Ueberlastung. const voll = computeDelay({ cycleTime: 90, greenTime: 0, capacity: 0, demand: 10, verfahren: 'HBS', mode: 'kfz', }); expect(voll.serviceLevel?.grade).toBe('F'); expect(voll.serviceLevel?.kriterium).toBe('ueberlastung'); // Dieselbe Aussage in der Kapazitaetsrechnung: 0 > 0 ist keine Ueberlastung. const kap = computeCapacity(0, 90, 2000, 0, { verfahren: 'HBS' }); expect(kap.degreeOfSaturation).toBe(0); expect(kap.notes.some((n) => n.code === 'auslastung-ueber-100')).toBe(false); }); it('warnt vor hoher Auslastung an der Schwelle der Projektvorgaben, nicht der Regelwerte', () => { // Regelwert 0,95; Projekt setzt 0,90 -> x = 0,92 muss gemeldet werden. const kap = computeCapacity(29, 90, 1800, 552, { verfahren: 'HBS', maxDegreeOfSaturation: 0.9, }); expect(kap.degreeOfSaturation).toBeCloseTo(0.92, 2); expect(kap.notes.some((n) => n.code === 'auslastung-kritisch')).toBe(true); expect(kap.notes.find((n) => n.code === 'auslastung-kritisch')?.message).toContain('0,90'); }); it('lehnt einen Instationaritaetsfaktor unter 1 ab und nimmt den Vorgabewert', () => { const wartezeit = computeDelay({ cycleTime: 90, greenTime: 30, capacity: 689, demand: 400, verfahren: 'HBS', mode: 'kfz', instationaritaetsfaktor: 0.8, }); expect(wartezeit.notes.some((n) => n.code === 'instationaritaetsfaktor-ungueltig')).toBe(true); expect(wartezeit.rechenweg.instationaritaetsfaktor).toBe( RILSA_DEFAULTS.capacity.instationaritaetsfaktor, ); }); }); describe('Saettigungsverkehrsstaerke (Befund B3)', () => { it('rechnet mit 2000 Kfz/h je Fahrstreifen (HBS, BASt V 400)', () => { expect(RILSA_DEFAULTS.capacity.saturationFlow).toBe(2000); expect(saturationFlow('geradeaus', 1)).toBe(2000); expect(saturationFlow('links', 1)).toBeCloseTo(1800, 6); }); it('bildet den Auslastungsgrad aus der ausgewiesenen, ganzzahligen Kapazitaet', () => { // 1992 * 11 / 90 = 243,47 -> ausgewiesen 243; x = 167 / 243, nicht 167 / 243,47. const kapazitaet = computeCapacity(10, 90, 1992, 167, { verfahren: 'HBS' }); expect(kapazitaet.capacity).toBe(243); expect(kapazitaet.degreeOfSaturation).toBeCloseTo(167 / 243, 4); }); }); describe('Qualitaetsstufen nach HBS 2015 (Befund B1)', () => { it('endet fuer Kfz bei E ohne Obergrenze und vergibt F nur bei Ueberlastung', () => { expect(serviceLevelFor(20, 'HBS')?.grade).toBe('A'); expect(serviceLevelFor(35, 'HBS')?.grade).toBe('B'); expect(serviceLevelFor(50, 'HBS')?.grade).toBe('C'); expect(serviceLevelFor(70, 'HBS')?.grade).toBe('D'); expect(serviceLevelFor(70.1, 'HBS')?.grade).toBe('E'); // Bis hierher hiess es: 100 s -> E, 100,1 s -> F. Nach HBS 2015 bleibt es E. expect(serviceLevelFor(100.1, 'HBS')?.grade).toBe('E'); expect(serviceLevelFor(500, 'HBS')?.grade).toBe('E'); expect(serviceLevelFor(500, 'HBS', { degreeOfSaturation: 0.99 })?.grade).toBe('E'); // ... und ein ueberlasteter Strom ist F, egal wie klein die Wartezeit ausfaellt. const f = serviceLevelFor(30, 'HBS', { degreeOfSaturation: 1.01 }); expect(f?.grade).toBe('F'); expect(f?.kriterium).toBe('ueberlastung'); expect(f?.label).toContain('überlastet'); // Genau 1,0 ist keine Ueberlastung (q > C, nicht q >= C). expect(serviceLevelFor(30, 'HBS', { degreeOfSaturation: 1 })?.grade).toBe('B'); expect(SERVICE_LEVEL_SCALES.HBS.kfz.some((s) => s.grade === 'F')).toBe(false); }); it('bewertet den OePNV nach der eigenen HBS-Tafel', () => { expect(serviceLevelFor(5, 'HBS', { mode: 'oepnv' })?.grade).toBe('A'); expect(serviceLevelFor(15, 'HBS', { mode: 'oepnv' })?.grade).toBe('B'); expect(serviceLevelFor(25, 'HBS', { mode: 'oepnv' })?.grade).toBe('C'); expect(serviceLevelFor(40, 'HBS', { mode: 'oepnv' })?.grade).toBe('D'); expect(serviceLevelFor(60, 'HBS', { mode: 'oepnv' })?.grade).toBe('E'); expect(serviceLevelFor(60.1, 'HBS', { mode: 'oepnv' })?.grade).toBe('F'); // Dieselben 25 s sind fuer Kfz Stufe B. expect(serviceLevelFor(25, 'HBS', { mode: 'kfz' })?.grade).toBe('B'); }); it('bewertet nach HCM weiter nach der HCM-Tafel, F ausserdem bei Ueberlastung', () => { expect(serviceLevelFor(15, 'HCM')?.grade).toBe('B'); expect(serviceLevelFor(80, 'HCM')?.grade).toBe('E'); expect(serviceLevelFor(80.1, 'HCM')?.grade).toBe('F'); expect(serviceLevelFor(80.1, 'HCM')?.kriterium).toBe('mittlere-wartezeit'); expect(serviceLevelFor(20, 'HCM', { degreeOfSaturation: 1.2 })?.kriterium).toBe('ueberlastung'); }); it('koppelt die Stufe F der Wartezeitrechnung an q > C', () => { const ueberlastet = computeDelay({ cycleTime: 90, greenTime: 30, capacity: 600, demand: 601, verfahren: 'HBS', mode: 'kfz', }); expect(ueberlastet.serviceLevel?.grade).toBe('F'); expect(ueberlastet.serviceLevel?.kriterium).toBe('ueberlastung'); expect(ueberlastet.notes.some((n) => n.code === 'ueberlastet')).toBe(true); const gerade = computeDelay({ cycleTime: 90, greenTime: 30, capacity: 600, demand: 600, verfahren: 'HBS', mode: 'kfz', }); expect(gerade.serviceLevel?.grade).not.toBe('F'); }); it('kfzServiceLevel liefert nie null', () => { expect(kfzServiceLevel(30, 'HBS').grade).toBe('B'); expect(kfzServiceLevel(30, 'HBS', 1.5).grade).toBe('F'); expect(kfzServiceLevel(10, 'HBS', undefined, 'oepnv').grade).toBe('B'); }); }); describe('Fussgaenger und Radverkehr: maximale Wartezeit (Befund B4)', () => { it('ermittelt die Sperrzeiten aus den Freigabezeitfenstern, auch ueber den Umlaufbeginn hinweg', () => { expect(sperrzeitenAusFenstern([{ start: 10, duration: 20 }], 90)).toEqual([70]); expect( sperrzeitenAusFenstern( [ { start: 0, duration: 20 }, { start: 50, duration: 10 }, ], 90, ), ).toEqual([30, 30]); // Fenster von 80 bis 100 = 80..90 und 0..10: Sperrzeit 10..80 = 70 s. expect(sperrzeitenAusFenstern([{ start: 80, duration: 20 }], 90)).toEqual([70]); expect(sperrzeitenAusFenstern([], 90)).toEqual([90]); expect(sperrzeitenAusFenstern([{ start: 0, duration: 90 }], 90)).toEqual([]); }); it('bewertet nach der HBS-Fuss/Rad-Tafel ueber die laengste Sperrzeit', () => { // Beispiel aus der VCD-Darstellung zum HBS S4: Umlauf 110 s, Rot 41 bis // 108 s -> maximale Wartezeit 67 s -> Stufe D. const furt = computeFussRadDelay({ cycleTime: 110, sperrzeiten: [67], verfahren: 'HBS', mode: 'fuss', }); expect(furt.maximumDelay).toBe(67); expect(furt.serviceLevel?.grade).toBe('D'); expect(furt.serviceLevel?.kriterium).toBe('maximale-wartezeit'); expect(furt.degreeOfSaturation).toBe(0); // Mittlere Wartezeit eines gleichverteilt eintreffenden Fussgaengers: 67^2 / (2*110). expect(furt.averageDelay).toBeCloseTo((67 * 67) / 220, 1); for (const [wert, stufe] of [ [30, 'A'], [40, 'B'], [55, 'C'], [70, 'D'], [85, 'E'], [85.1, 'F'], ] as const) { expect(serviceLevelFor(wert, 'HBS', { mode: 'fuss' })?.grade, `${wert} s`).toBe(stufe); expect(serviceLevelFor(wert, 'HBS', { mode: 'rad' })?.grade, `${wert} s`).toBe(stufe); } }); it('bewertet bei zwei Freigaben die laengere Sperrzeit, nicht die Summe', () => { const zweimal = computeFussRadDelay({ cycleTime: 90, sperrzeiten: sperrzeitenAusFenstern( [ { start: 0, duration: 15 }, { start: 45, duration: 15 }, ], 90, ), verfahren: 'HBS', mode: 'rad', }); expect(zweimal.maximumDelay).toBe(30); expect(zweimal.serviceLevel?.grade).toBe('A'); }); it('vergibt nach HCM fuer Fussgaenger und Radverkehr keine Stufe und sagt das', () => { const furt = computeFussRadDelay({ cycleTime: 90, sperrzeiten: [60], verfahren: 'HCM', mode: 'fuss', }); expect(furt.serviceLevel).toBeNull(); expect(furt.notes.some((n) => n.code === 'keine-fuss-rad-tafel')).toBe(true); expect(serviceLevelFor(60, 'HCM', { mode: 'rad' })).toBeNull(); }); });