import { describe, expect, it } from 'vitest'; import { computeCapacity } from '@/domain/rilsa/capacity'; import { computeDelay, computeFussRadDelay, serviceLevelFor } from '@/domain/rilsa/delay'; import { computeIntergreen } from '@/domain/rilsa/intergreen'; import { computeCycleTime } from '@/domain/rilsa/cycle'; import { resolveRilsaSettings } from '@/domain/rilsa/settings'; import { ANLAGENART_GRENZEN, ENGSTELLE_PRAXIS, OEPNV_ANFAHRBESCHLEUNIGUNG, RILSA_DEFAULTS, RSA_EINSTREIFIG, SAFETY_FLOORS, VZUL_OBERGRENZE_LSA, } from '@/domain/rilsa/constants'; import { engstellenFolgen, engstellenPraxisHinweis } from '@/domain/rilsa/engstelle'; import { furtFreigabebedarf } from '@/domain/rilsa/signalTimes'; import { UMLAUFZEIT_ERSATZWERT_VERMERK, buildSignalPlan, mindestfreigabezeitDerGruppe, umlaufzeitIstErsatzwert, } from '@/domain/plan/signalPlan'; import { einstreifigBefunde, hoechstgeschwindigkeitBefunde } from '@/domain/validation/rules'; import { createConflict, createEmptyProject, createPhase, createSignalGroup, createStandardIntersectionProject, } from '@/domain/model/factory'; import { CURRENT_SCHEMA_VERSION, type Project, type SignalGroup } from '@/domain/model/project'; import type { ClearingStream, EnteringStream, VehicleClass } from '@/domain/rilsa/types'; /* * Die Zahlenbeispiele zu den Befunden: Kapazitaet, Wartezeit und * Qualitaetsstufe, Zwischenzeiten, Furt, Schranken und ersetzte Eingaben. * * Grundsatz: Was gedruckt wird, muss sich aus den gedruckten Groessen * nachrechnen lassen. Das gilt auch fuer die Zahlenbeispiele der * Dokumentation - eine Zahl in einer Beschreibung, die sich aus keiner * Rechnung des Programms ergibt, ist als Begruendung wertlos (siehe * engstelle.test.ts: 125 s Zwischenzeit, die es nirgends gab). Dieser Test * haelt die Beispiele an die Fachlogik. * * Erster Block (B1-B4): Kfz-Strom, ein Fahrstreifen geradeaus, tU = 90 s, * tF = 30 s. Zweiter Block (A1-A4): die Zwischenzeitbeispiele zur Fassung * 5.4.0. */ const TU = 90; const TF = 30; const QS = RILSA_DEFAULTS.capacity.saturationFlow; function hbs(q: number) { const kap = computeCapacity(TF, TU, QS, q, { verfahren: 'HBS' }); const delay = computeDelay({ cycleTime: TU, greenTime: TF, capacity: kap.capacity, demand: q, verfahren: 'HBS', mode: 'kfz', abflusszeit: kap.abflusszeit, }); return { kap, delay }; } /** Der Altstand 5.3.5: 1800 Fz/h, HCM-Formel mit g = tF - unter dem Etikett HBS. */ function altstand(q: number) { const kap = computeCapacity(TF, TU, 1800, q, { verfahren: 'HCM' }); const delay = computeDelay({ cycleTime: TU, greenTime: TF, capacity: kap.capacity, demand: q, verfahren: 'HCM', mode: 'kfz', abflusszeit: kap.abflusszeit, }); return { kap, delay }; } describe('Zahlenbeispiele zu Kapazitaet, Wartezeit und Qualitaetsstufe (B1-B4)', () => { it('Kapazitaet: 1800 * 30 / 90 = 600 bisher, 2000 * 31 / 90 = 689 jetzt (+15 %)', () => { expect(QS).toBe(2000); expect(altstand(550).kap.capacity).toBe(600); const { kap } = hbs(550); expect(kap.abflusszeit).toBe(31); expect(kap.capacity).toBe(689); expect(Math.round(((689 - 600) / 600) * 100)).toBe(15); }); it('q = 550: bisher x 0,92 -> 49,9 s, jetzt x 0,80 -> 43,3 s, beide Stufe C', () => { const alt = altstand(550); expect(alt.kap.degreeOfSaturation).toBeCloseTo(0.92, 2); expect(alt.delay.averageDelay).toBeCloseTo(49.9, 1); const neu = hbs(550); expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.8, 2); expect(neu.delay.averageDelay).toBeCloseTo(43.3, 1); expect(neu.delay.serviceLevel?.grade).toBe('C'); }); it('q = 655: bisher x 1,09 -> 94,2 s; jetzt x 0,95 -> 28,8 + 82,3 = 111,1 s, Stufe E', () => { const alt = altstand(655); expect(alt.kap.degreeOfSaturation).toBeCloseTo(1.09, 2); expect(alt.delay.averageDelay).toBeCloseTo(94.2, 1); const neu = hbs(655); expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.95, 2); expect(neu.delay.uniformDelay).toBeCloseTo(28.8, 1); expect(neu.delay.incrementalDelay).toBeCloseTo(82.3, 1); expect(neu.delay.averageDelay).toBeCloseTo(111.1, 1); expect(neu.delay.serviceLevel?.grade).toBe('E'); expect(neu.delay.serviceLevel?.kriterium).toBe('mittlere-wartezeit'); }); it('q = 662: bisher 98,3 s; jetzt 120,0 s bei x 0,96 - Stufe E, nicht F', () => { expect(altstand(662).delay.averageDelay).toBeCloseTo(98.3, 1); const neu = hbs(662); expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.96, 2); expect(neu.delay.averageDelay).toBeCloseTo(120.0, 1); expect(neu.delay.serviceLevel?.grade).toBe('E'); }); it('q = 700: bisher 122,1 s; jetzt x 1,02 -> 173,4 s, Stufe F wegen Ueberlastung', () => { expect(altstand(700).delay.averageDelay).toBeCloseTo(122.1, 1); const neu = hbs(700); expect(neu.kap.degreeOfSaturation).toBeCloseTo(1.02, 2); expect(neu.delay.averageDelay).toBeCloseTo(173.4, 1); expect(neu.delay.serviceLevel?.grade).toBe('F'); expect(neu.delay.serviceLevel?.kriterium).toBe('ueberlastung'); }); it('gleiche Kapazitaet und gleicher Auslastungsgrad: HCM-Formel rund 53 s, HBS 111 s', () => { // C = 689, q = 655 -> x = 0,95; nur die Formel wechselt. const gemeinsam = { cycleTime: TU, greenTime: TF, capacity: 689, demand: 655, mode: 'kfz' as const, }; const hcm = computeDelay({ ...gemeinsam, verfahren: 'HCM' }); const hbsErgebnis = computeDelay({ ...gemeinsam, verfahren: 'HBS', abflusszeit: 31 }); expect(hcm.averageDelay).toBeGreaterThanOrEqual(52.5); expect(hcm.averageDelay).toBeLessThanOrEqual(53.5); expect(hbsErgebnis.averageDelay).toBeCloseTo(111.1, 1); // "etwa das Doppelte" bzw. umgekehrt "etwa die Haelfte" expect(hbsErgebnis.averageDelay / hcm.averageDelay).toBeGreaterThan(1.9); expect(hbsErgebnis.averageDelay / hcm.averageDelay).toBeLessThan(2.3); }); it('Fussgaengerfurt tU 90 s, 12 s Freigabe: Sperrzeit 78 s -> Stufe E; HBS-S4-Beispiel 67 s -> D', () => { const furt = computeFussRadDelay({ cycleTime: 90, sperrzeiten: [78], verfahren: 'HBS', mode: 'fuss', }); expect(furt.maximumDelay).toBe(78); expect(furt.serviceLevel?.grade).toBe('E'); expect(furt.serviceLevel?.kriterium).toBe('maximale-wartezeit'); const beispiel = computeFussRadDelay({ cycleTime: 110, sperrzeiten: [67], verfahren: 'HBS', mode: 'fuss', }); expect(beispiel.serviceLevel?.grade).toBe('D'); }); it('OePNV mit 25 s: Kfz-Tafel B, OePNV-Tafel C', () => { expect(serviceLevelFor(25, 'HBS', { mode: 'kfz' })?.grade).toBe('B'); expect(serviceLevelFor(25, 'HBS', { mode: 'oepnv' })?.grade).toBe('C'); }); it('LISA+ K5: tW,G 37,9 s, tW,R 21,1 s, tW 59,0 s, Stufe D', () => { const kap = computeCapacity(10, 90, 1992, 167, { verfahren: 'HBS' }); expect(kap.capacity).toBe(243); const delay = computeDelay({ cycleTime: 90, greenTime: 10, capacity: 243, demand: 167, verfahren: 'HBS', mode: 'kfz', abflusszeit: 11, }); expect(delay.uniformDelay).toBeCloseTo(37.9, 1); expect(delay.incrementalDelay).toBeCloseTo(21.1, 1); expect(delay.averageDelay).toBeCloseTo(59.0, 1); expect(delay.rechenweg.reststau).toBeCloseTo(1.43, 2); expect(delay.serviceLevel?.grade).toBe('D'); // tW,R ist die Differenz auf den ausgegebenen Ziffern, damit "37,9 + 21,1 = // 59,0" im Ausdruck aufgeht. Unabhaengig gerundet ergaebe 37,85 + 21,17 = // 59,02 die Zeile "37,9 + 21,2 = 59,0", die sich selbst widerlegt. expect(delay.uniformDelay + delay.incrementalDelay).toBeCloseTo(delay.averageDelay, 6); }); it('ohne Kapazitaet UND ohne Nachfrage keine Stufe; ohne Kapazitaet mit Nachfrage F', () => { const leer = computeDelay({ cycleTime: TU, greenTime: TF, capacity: 0, demand: 0, verfahren: 'HBS', mode: 'kfz', }); expect(leer.serviceLevel).toBeNull(); const ueberlastet = computeDelay({ cycleTime: TU, greenTime: TF, capacity: 0, demand: 100, verfahren: 'HBS', mode: 'kfz', }); expect(ueberlastet.serviceLevel?.grade).toBe('F'); expect(ueberlastet.serviceLevel?.kriterium).toBe('ueberlastung'); }); }); /* * Zahlenbeispiele zu den kritischen Befunden A1 bis A4 (Fassung 5.4.0). Die * Fachtests dazu stehen in raeumgeschwindigkeitSchranken.test.ts, * messgrundlage.test.ts und oepnvZwischenzeit.test.ts; hier geht es allein * darum, dass die gedruckten Zahlen aus der Rechnung des Programms folgen. */ const KFZ_EINFAHREND_10M: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 10, }; function radRaeumend(clearingDistance: number): ClearingStream { return { mode: 'rad', movement: 'geradeaus', clearingDistance, vehicleClass: 'rad', vZul: 0 }; } function bahnOhneHalt(extra: Partial = {}): ClearingStream { // 25 m Grundraeumweg + 15 m Strassenbahn = 40 m Raeumweg. return { mode: 'oepnv', movement: 'geradeaus', clearingDistance: 25, vehicleClass: 'strassenbahn', vZul: 50, ...extra, }; } describe('Zahlenbeispiele zu Raeum- und Einfahrzeiten (A1-A4)', () => { it('A1: Rad raeumt 20 m, Kfz faehrt 10 m ein - bisher 4,1 -> 5 s, RiLSA 5,1 -> 6 s', () => { // Der Altstand (5,0 m/s) laesst sich mit dem heutigen Programm nicht mehr // rechnen - die Rad-Schranke kappt ihn. Die alte Zeile wird deshalb mit // derselben Formel tz = tue + sr/vr - se/ve nachgestellt, die auch das // Programm rechnet; der neue Wert kommt aus dem Programm selbst. const te = 10 / RILSA_DEFAULTS.enteringSpeed.kfz; const alt = 1 + 20 / 5 - te; expect(alt).toBeCloseTo(4.1, 1); expect(Math.ceil(alt)).toBe(5); const neu = computeIntergreen(radRaeumend(20), KFZ_EINFAHREND_10M); expect(neu.clearingSpeed).toBe(4.0); expect(neu.raw).toBeCloseTo(5.1, 1); expect(neu.intergreen).toBe(6); // "1 s zu frueh Freigabe" expect(neu.intergreen - Math.ceil(alt)).toBe(1); }); it('A1: bei 40 m Raeumweg waechst das Defizit auf 2 s (9 statt 11 s)', () => { const te = 10 / RILSA_DEFAULTS.enteringSpeed.kfz; expect(Math.ceil(1 + 40 / 5 - te)).toBe(9); const neu = computeIntergreen(radRaeumend(40), KFZ_EINFAHREND_10M); expect(neu.intergreen).toBe(11); }); it('A1: 8 m/s als Rad-Ueberschreibung wird jetzt mit Fehler auf 4 m/s gekappt', () => { const result = computeIntergreen( { ...radRaeumend(20), clearingSpeedOverride: 8 }, KFZ_EINFAHREND_10M, ); expect(SAFETY_FLOORS.maxClearingSpeedRad).toBe(4.0); expect(result.clearingSpeed).toBe(4.0); expect(result.notes.some((n) => n.severity === 'fehler')).toBe(true); }); it('A3: 15 m/s aus den Vorgaben werden auf die 10-m/s-Schranke gekappt (Raeumzeit sonst um ein Drittel kuerzer)', () => { const { defaults, notes } = resolveRilsaSettings({ clearingSpeedOepnv: 15 }); expect(defaults.clearingSpeed.oepnv).toBe(SAFETY_FLOORS.maxClearingSpeedKfz); expect(SAFETY_FLOORS.maxClearingSpeedKfz).toBe(10); expect(notes.some((n) => n.severity === 'fehler')).toBe(true); // 40 m: 40/15 = 2,67 s statt 40/10 = 4,0 s - ein Drittel weniger. expect((40 / 10 - 40 / 15) / (40 / 10)).toBeCloseTo(1 / 3, 6); }); it('A4: derselbe Wegfehler wirkt an der Furt fast zehnfach (1,2 statt 10 m/s)', () => { expect(RILSA_DEFAULTS.clearingSpeed.fuss).toBe(1.2); expect(RILSA_DEFAULTS.clearingSpeed.kfzStraight).toBe(10); const faktor = RILSA_DEFAULTS.clearingSpeed.kfzStraight / RILSA_DEFAULTS.clearingSpeed.fuss; expect(faktor).toBeGreaterThan(8); expect(faktor).toBeLessThan(10); }); it('A2: Bahn ohne Halt, Vmax 50, 40 m Raeumweg - bisher 0 + 40/10 = 4,0 s, RiLSA 5 + 40/13,9 = 7,9 s', () => { // Vor Einfahrzeitabzug, deshalb Einfahrweg 0. const neu = computeIntergreen(bahnOhneHalt(), { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0, }); expect(neu.clearingPath).toBe(40); expect(neu.crossingTime).toBe(5); expect(neu.clearingSpeed).toBeCloseTo(50 / 3.6, 6); expect(neu.raw).toBeCloseTo(7.9, 1); expect(neu.intergreen).toBe(8); // Altstand: Ueberfahrzeit 0 s, feste 10 m/s. const alt = 0 + 40 / 10; expect(alt).toBe(4.0); // "rund 4 s zu wenig" expect(Math.round(neu.raw - alt)).toBe(4); }); it('A2: dieselbe Bahn mit Halt vor dem Knotenpunkt - 0 + Wurzel(2 * 40 / 1,0) = 8,9 s', () => { const halt = computeIntergreen(bahnOhneHalt({ haltVorKnoten: true }), { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0, }); expect(OEPNV_ANFAHRBESCHLEUNIGUNG.strassenbahn).toBe(1.0); expect(halt.crossingTime).toBe(0); expect(halt.raeumansatz).toBe('anfahren'); expect(halt.raw).toBeCloseTo(8.9, 1); }); }); /* * Zahlenbeispiele zu den Befunden C1 bis C6: Pruefungen, die fehlten - zur * Furt, zu den Schranken je Anlagenart, zu den Signalzeiten und zu den * Randbedingungen der Anordnung. Die Fachtests stehen in * fehlendePruefungen.test.ts; hier geht es allein darum, dass jede gedruckte * Zahl aus der Rechnung des Programms folgt - und dass Regelwerkswert und * Praxiswert je als solcher gefuehrt sind. */ const DATUM_C = new Date('2026-01-01T00:00:00Z'); /** Ein Kfz-Strom und eine Furt, die ihn quert; Furtlaenge = Raeumweg der Furt. */ function furtProjekt(furtlaenge: number, furt: Partial = {}): Project { const basis = createEmptyProject('Furt', DATUM_C); const k = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); const f: SignalGroup = { ...createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 }), ...furt }; const phaseK = createPhase('Kfz', [k.id]); const phaseF = createPhase('Fuss', [f.id]); return { ...basis, signalGroups: [k, f], conflicts: [ { ...createConflict(f.id, k.id), clearingDistance: furtlaenge, enteringDistance: 3 }, { ...createConflict(k.id, f.id), clearingDistance: 15, enteringDistance: 5 }, ], phases: [phaseK, phaseF], program: { ...basis.program, phaseOrder: [phaseK.id, phaseF.id] }, }; } /** Einstreifige Verkehrsfuehrung, zwei Richtungen; die Engstelle ist der Raeumweg. */ function engstellenProjekt(laenge: number, vZul = 50): Project { const basis = createEmptyProject('Arbeitsstelle', DATUM_C, 'einstreifig'); const a1 = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0, vZul }); const a2 = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1, vZul }); const p1 = createPhase('Richtung 1', [a1.id]); const p2 = createPhase('Richtung 2', [a2.id]); return { ...basis, intersection: { ...basis.intersection, vZul }, signalGroups: [a1, a2], conflicts: [ { ...createConflict(a1.id, a2.id), clearingDistance: laenge, enteringDistance: 0 }, { ...createConflict(a2.id, a1.id), clearingDistance: laenge, enteringDistance: 0 }, ], phases: [p1, p2], program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, }; } function furtGruppe(project: Project): SignalGroup { return project.signalGroups.find((g) => g.mode === 'fuss')!; } describe('Zahlenbeispiele zu Furt, Geschwindigkeit, Engstelle und Umlaufzeit (C1-C6)', () => { it('C1: Furt 14 m, vr 1,2 m/s - halbe Furt 7 m / 1,2 m/s = 5,83 s, aufgerundet 6 s (bisher 5 s)', () => { expect(RILSA_DEFAULTS.minGreen.fuss).toBe(5); expect(RILSA_DEFAULTS.clearingSpeed.fuss).toBe(1.2); const halb = furtFreigabebedarf(14, 1.2, false); expect(halb.furtweg).toBe(7); expect(halb.zeitbedarf).toBeCloseTo(5.83, 2); const projekt = furtProjekt(14); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.regelwert).toBe(5); expect(mfz.wert).toBe(6); expect(mfz.massgebend).toBe('furt'); // Der Hinweis nennt den Rechenweg, wie er in den Unterlagen steht - Furtweg // mit zwei, Zeitbedarf mit drei Nachkommastellen. const notiz = buildSignalPlan(projekt).notes.find( (n) => n.code === 'mindestfreigabezeit-aus-furtlaenge', ); expect(notiz?.message).toContain('7,00 m / 1,2 m/s = 5,833 s, aufgerundet 6 s'); }); it('C1: Furt 12,01 m - 6,01 m / 1,2 m/s = 5,004 s -> 6 s; der gedruckte Rechenweg gibt sein Ergebnis her', () => { const projekt = furtProjekt(12.01); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.furt?.zeitbedarf).toBeCloseTo(5.004, 3); expect(mfz.furt?.zeitbedarfGerundet).toBe(6); expect(mfz.wert).toBe(6); const notiz = buildSignalPlan(projekt).notes.find( (n) => n.code === 'mindestfreigabezeit-aus-furtlaenge', ); expect(notiz?.message).toContain('6,01 m / 1,2 m/s = 5,004 s, aufgerundet 6 s'); }); it('C1: dieselbe Furt mit Zusatzeinrichtung fuer Blinde und Sehbehinderte - ganze Furt 14 m / 1,2 m/s = 11,67 s -> 12 s', () => { const projekt = furtProjekt(14, { blindenzusatz: true }); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.furt?.ganzeFurt).toBe(true); expect(mfz.furt?.furtweg).toBe(14); expect(mfz.furt?.zeitbedarf).toBeCloseTo(11.67, 2); expect(mfz.wert).toBe(12); }); it('C1: erhoehter Zeitbedarf rechnet mit 1,0 m/s - 7 m / 1,0 m/s = 7 s', () => { expect(RILSA_DEFAULTS.clearingSpeed.fussReducedMobility).toBe(1.0); const projekt = furtProjekt(14, { reducedMobility: true }); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.furt?.vr).toBe(1.0); expect(mfz.wert).toBe(7); }); it('C1: Furt 20 m - halbe Furt 10 m / 1,2 m/s = 8,33 s -> 9 s; die Fussgaengerphase erhaelt mindestens 9 s', () => { const projekt = furtProjekt(20); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.furt?.zeitbedarf).toBeCloseTo(8.33, 2); expect(mfz.wert).toBe(9); // "Umlauf kann wachsen": Verteilung und Pruefung ziehen dieselbe Untergrenze; // die Phase der Furt erhaelt mindestens die 9 s, nicht mehr 5 s. const plan = buildSignalPlan(projekt); expect(plan.phases.find((p) => p.name === 'Fuss')!.duration).toBeGreaterThanOrEqual(9); expect(plan.groups.find((g) => g.mode === 'fuss')!.times.minGreen).toBe(9); // Eine von Hand auf 5 s gesetzte Fussgaengerphase - bis dahin stillschweigend // zulaessig - wird auf die 9 s angehoben und als zu kurz gemeldet. const vonHand: Project = { ...projekt, phases: projekt.phases.map((p) => (p.name === 'Fuss' ? { ...p, manualGreen: 5 } : p)), }; const planVonHand = buildSignalPlan(vonHand); expect(planVonHand.phases.find((p) => p.name === 'Fuss')!.duration).toBe(9); expect(planVonHand.notes.some((n) => n.code === 'phase-freigabezeit-zu-kurz')).toBe(true); // Zum Vergleich: dieselbe Furt mit 12 m bleibt bei 5 s. expect( buildSignalPlan(furtProjekt(12)).groups.find((g) => g.mode === 'fuss')!.times.minGreen, ).toBe(5); }); it('C1: Furt 12 m - halbe Furt 6 m / 1,2 m/s = 5,0 s, der Regelwert bleibt; Beispielknotenpunkt unveraendert', () => { const projekt = furtProjekt(12); const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); expect(mfz.wert).toBe(5); expect(mfz.massgebend).toBe('regelwert'); const plan = buildSignalPlan(createStandardIntersectionProject('Vorlage', DATUM_C)); for (const g of plan.groups.filter((g) => g.mode === 'fuss')) expect(g.times.minGreen).toBe(5); }); it('C1: Schemastand 10 auf 11; ein fehlendes Feld heisst "ohne Zusatz" (halbe Furt)', () => { // Der Schemastand ist seither weiter gestiegen (12 seit Fassung 5.5.0 mit // den Fragen E1/E2, 13 seit Fassung 5.6.0 mit dem Lastzuganteil); die // Aussage von C1 bleibt davon unberuehrt - das Feld blindenzusatz kam mit // der 11. expect(CURRENT_SCHEMA_VERSION).toBe(19); const projekt = furtProjekt(14); const ohneFeld: SignalGroup = { ...furtGruppe(projekt) }; delete (ohneFeld as { blindenzusatz?: boolean }).blindenzusatz; const mfz = mindestfreigabezeitDerGruppe( { ...projekt, signalGroups: projekt.signalGroups.map((g) => (g.mode === 'fuss' ? ohneFeld : g)), }, ohneFeld, RILSA_DEFAULTS, ); expect(mfz.furt?.ganzeFurt).toBe(false); expect(mfz.wert).toBe(6); }); it('C2/C6: 70 km/h nach VwV-StVO, 50 km/h nach RSA 21 - 80 km/h am Knotenpunkt: Warnung, bisher keine', () => { expect(VZUL_OBERGRENZE_LSA).toBe(70); expect(RSA_EINSTREIFIG.vZulRegel).toBe(50); const knoten = createStandardIntersectionProject('vZul', DATUM_C); const mit80: Project = { ...knoten, signalGroups: knoten.signalGroups.map((g) => (g.name === 'K1' ? { ...g, vZul: 80 } : g)), }; const befunde = hoechstgeschwindigkeitBefunde(mit80); expect( befunde.filter((f) => f.rule === 'signalgruppen.vzul-ueber-70' && f.severity === 'warnung'), ).toHaveLength(1); // Am Knotenpunkt keine RSA-Regel; 70 km/h selbst ist noch kein Befund. expect(befunde.filter((f) => f.rule === 'einstreifig.vzul')).toEqual([]); expect(hoechstgeschwindigkeitBefunde(knoten)).toEqual([]); }); it('C6: einstreifig 60 km/h - Hinweis je Kfz-Gruppe und fuer den Anlagenbereich; 80 km/h - Warnung, dazu C2', () => { const hinweise = hoechstgeschwindigkeitBefunde(engstellenProjekt(200, 60)); const rsa60 = hinweise.filter((f) => f.rule === 'einstreifig.vzul'); // zwei Kfz-Gruppen + Anlagenbereich expect(rsa60).toHaveLength(3); expect(rsa60.every((f) => f.severity === 'hinweis')).toBe(true); expect(hinweise.filter((f) => f.rule.endsWith('vzul-ueber-70'))).toEqual([]); const warnungen = hoechstgeschwindigkeitBefunde(engstellenProjekt(200, 80)); expect( warnungen.filter((f) => f.rule === 'einstreifig.vzul' && f.severity === 'warnung'), ).toHaveLength(3); expect(warnungen.filter((f) => f.rule === 'signalgruppen.vzul-ueber-70')).toHaveLength(2); expect(warnungen.filter((f) => f.rule === 'projekt.vzul-ueber-70')).toHaveLength(1); }); it('C3: 50 m und 500 Kfz/h - Anordnungskriterien einmal je Projekt, kurze Engstelle bis 50 m, Typklasse', () => { expect(RSA_EINSTREIFIG.kurzeEngstelleBis).toBe(50); expect(RSA_EINSTREIFIG.verkehrsstaerkeRichtwert).toBe(500); const kurz = einstreifigBefunde(engstellenProjekt(50)); expect(kurz.filter((f) => f.rule === 'einstreifig.rsa-anordnungskriterien')).toHaveLength(1); expect(kurz.filter((f) => f.rule === 'einstreifig.kurze-engstelle')).toHaveLength(1); expect(kurz.filter((f) => f.rule === 'einstreifig.ztv-typklasse')).toHaveLength(1); expect(kurz.every((f) => f.severity === 'hinweis')).toBe(true); expect( einstreifigBefunde(engstellenProjekt(51)).some( (f) => f.rule === 'einstreifig.kurze-engstelle', ), ).toBe(false); // Am Knotenpunkt keine dieser Regeln. expect(einstreifigBefunde(createStandardIntersectionProject('Knoten', DATUM_C))).toEqual([]); }); it('C4: 120 s von Hand am Knotenpunkt - "ausserhalb des ueblichen Bereichs von 30 bis 90 s"', () => { // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s, // die dieses Programm gesetzt hatte. expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMin).toBe(30); expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMax).toBe(90); const roh = createStandardIntersectionProject('Manuell', DATUM_C); const manuell: Project = { ...roh, program: { ...roh.program, method: 'manuell', manualCycleTime: 120 }, }; const treffer = buildSignalPlan(manuell).notes.filter( (n) => n.code === 'umlaufzeit-ausserhalb-regelbereich', ); expect(treffer).toHaveLength(1); expect(treffer[0]!.severity).toBe('hinweis'); expect(treffer[0]!.message).toBe( 'Die Umlaufzeit 120 s liegt außerhalb des üblichen Bereichs von 30 bis 90 s.', ); // 150 s bei einstreifiger Fuehrung liegen im Regelbereich (60-180 s). expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.recommendedMax).toBe(180); const eng = engstellenProjekt(300); const eng150: Project = { ...eng, program: { ...eng.program, method: 'manuell', manualCycleTime: 150 }, }; expect( buildSignalPlan(eng150).notes.some((n) => n.code === 'umlaufzeit-ausserhalb-regelbereich'), ).toBe(false); }); it('C5: 400 m Hinweis, 600 m Warnung (Behoerdenpraxis); die Wartezeitschranke schlug bisher erst ab 835 m an', () => { expect(ENGSTELLE_PRAXIS).toEqual({ hinweisAb: 400, warnungAb: 600 }); expect(engstellenPraxisHinweis(399)).toBeNull(); expect(engstellenPraxisHinweis(400)?.stufe).toBe('hinweis'); expect(engstellenPraxisHinweis(600)?.stufe).toBe('warnung'); /* * Vor C5 gab es zwischen 400 m und der Wartezeitschranke keine Meldung. * Wo diese Schranke greift, hat sich mit 5.27.0 verschoben: Das * Engstellenverfahren des Abschnitts 5.2 raeumt bei zulaessig 50 km/h mit * 40 km/h statt mit 10 m/s, und der Raeumweg traegt keine Fahrzeuglaenge * mehr. Aus rund 835 m sind rund 923 m geworden - 5 s Freigabe + 2 x * Zwischenzeit; bei 922 m ist tz = 87 s (5 + 174 = 179 s), bei 923 m * tz = 88 s (5 + 176 = 181 s). Der Befund C5 traegt dadurch weiter: Ohne * die Behoerdenpraxis waere die erste Meldung noch spaeter gekommen. */ expect(ANLAGENART_GRENZEN.einstreifig.warteZeitWarnung).toBe(180); expect(engstellenFolgen(922, 50).zwischenzeit).toBe(87); expect(engstellenFolgen(922, 50).wartezeit).toBe(179); expect(engstellenFolgen(922, 50).stufe).toBe('hinweis'); expect(engstellenFolgen(923, 50).zwischenzeit).toBe(88); expect(engstellenFolgen(923, 50).stufe).toBe('warnung'); // Und das Beispiel mit 400 m: 40 s Zwischenzeit, 120 s Umlauf bei 20 s Freigabe. expect(engstellenFolgen(400, 50).zwischenzeit).toBe(40); expect(engstellenFolgen(400, 50).praxisHinweis?.stufe).toBe('hinweis'); expect(engstellenFolgen(700, 50).praxisHinweis?.stufe).toBe('warnung'); // Im Bericht: Referenz nennt die Einordnung. const [befund] = einstreifigBefunde(engstellenProjekt(600)).filter( (f) => f.rule === 'einstreifig.laenge-behoerdenpraxis', ); expect(befund?.severity).toBe('warnung'); expect(befund?.reference).toBe('Behördenpraxis (z. B. Hessen Mobil), kein Regelwerkswert'); }); }); /* * Zahlenbeispiele zu den Befunden C7 bis C12: Exportsperre und stille * Ersetzungen - an Zwischenzeit, Signalzeiten, Umlaufzeit, Vorgabewerten und * Laengenzuschlag. Die Fachtests stehen in stilleErsetzungen.test.ts; hier geht * es allein darum, dass jede gedruckte Zahl und jeder zitierte Wortlaut aus der * Rechnung des Programms folgt. C7 und C12 (Ausgabe-Ansicht, Assistent) haben * keine Zahl im Fachkern und stehen deshalb nicht hier. */ /** Zwei Kfz-Gruppen in zwei Phasen; die zweite Gruppe laesst sich anpassen. */ function zweiKfzGruppen( zweite: Partial = {}, anlagenart: 'knotenpunkt' | 'einstreifig' = 'knotenpunkt', rilsa: Project['settings']['rilsa'] = {}, ): Project { const basis = createEmptyProject('Zwei Gruppen', DATUM_C, anlagenart); const a = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); const b: SignalGroup = { ...createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }), ...zweite }; const phase1 = createPhase('Phase 1', [a.id]); const phase2 = createPhase('Phase 2', [b.id]); return { ...basis, signalGroups: [a, b], conflicts: [ { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8 }, { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 }, ], phases: [phase1, phase2], program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, settings: { ...basis.settings, rilsa }, }; } describe('Zahlenbeispiele zu gekappten und ersetzten Eingaben (C7-C12)', () => { it('C8: Kfz raeumt 22 m, faehrt 8 m ein - 0 m: 3 + 2,20 - 0,72 = 4,48 -> 5 s; 6 m: 3 + 2,80 - 0,72 = 5,08 -> 6 s wie beim Pkw', () => { const raeumend: ClearingStream = { mode: 'kfz', movement: 'geradeaus', clearingDistance: 22, vehicleClass: 'traktor' as VehicleClass, vZul: 50, }; const einfahrend: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8 }; // Der Altstand (0 m) laesst sich mit dem heutigen Programm nicht mehr // rechnen; die alte Zeile wird mit derselben Formel nachgestellt. const te = 8 / RILSA_DEFAULTS.enteringSpeed.kfz; expect(te).toBeCloseTo(0.72, 2); const alt = 3 + 22 / 10 - te; expect(alt).toBeCloseTo(4.48, 2); expect(Math.ceil(alt)).toBe(5); const neu = computeIntergreen(raeumend, einfahrend); expect(neu.vehicleLength).toBe(SAFETY_FLOORS.minVehicleLengthKfz); expect(neu.vehicleLength).toBe(6); expect(neu.clearingPath).toBe(28); expect(neu.crossingTime).toBe(3); expect(neu.raw).toBeCloseTo(5.08, 2); expect(neu.intergreen).toBe(6); expect( neu.notes.some((n) => n.code === 'fahrzeugart-unbekannt' && n.severity === 'fehler'), ).toBe(true); // "wie beim Pkw" const pkw = computeIntergreen({ ...raeumend, vehicleClass: 'pkw' }, einfahrend); expect(pkw.intergreen).toBe(6); expect(pkw.raw).toBeCloseTo(neu.raw, 6); // Fussgaenger: 0 m ohne Meldung. const fuss = computeIntergreen( { mode: 'fuss', movement: 'querung', clearingDistance: 12, vehicleClass: 'traktor' as VehicleClass, vZul: 0, }, einfahrend, ); expect(fuss.vehicleLength).toBe(0); expect(fuss.notes.some((n) => n.code === 'fahrzeugart-unbekannt')).toBe(false); }); it('C9: 3 s eingetragen - "sie unterschreitet den Regelwert der Verkehrsart von 5 s. Massgebend bleibt 5 s; die Vorgabe hat keine Wirkung."', () => { const projekt = zweiKfzGruppen({ minGreenOverride: 3 }); const plan = buildSignalPlan(projekt); const k2 = projekt.signalGroups.find((g) => g.name === 'K2')!; expect(plan.groups.find((g) => g.groupId === k2.id)!.times.minGreen).toBe(5); const [notiz] = plan.notes.filter( (n) => n.code === 'mindestfreigabezeit-vorgabe-unterschritten', ); expect(notiz?.severity).toBe('warnung'); expect(notiz?.message).toBe( 'Für die Signalgruppe "K2" ist eine Mindestfreigabezeit von 3 s eingetragen; sie ' + 'unterschreitet den Regelwert der Verkehrsart von 5 s. Maßgebend bleibt 5 s; die Vorgabe ' + 'hat keine Wirkung.', ); // 5,5 s an einer 14-m-Furt (Furtbedarf 6 s): "unterschreitet den Furtbedarf von 6 s". const furt = furtProjekt(14, { minGreenOverride: 5.5 }); expect(mindestfreigabezeitDerGruppe(furt, furtGruppe(furt), RILSA_DEFAULTS).wert).toBe(6); const [furtNotiz] = buildSignalPlan(furt).notes.filter( (n) => n.code === 'mindestfreigabezeit-vorgabe-unterschritten', ); expect(furtNotiz?.message).toContain('5,5 s eingetragen'); expect(furtNotiz?.message).toContain('unterschreitet den Furtbedarf von 6 s'); expect(furtNotiz?.message).toContain('Maßgebend bleibt 6 s'); }); it('C10: 250 s am Knotenpunkt -> 120 s mit Fehler ("nach RiLSA nicht vorzusehen"); einstreifig 250 s ohne Befund, 400 s -> 300 s; 5 s -> 30 s ("nicht schaltbar")', () => { expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120); expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.max).toBe(120); expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300); expect(SAFETY_FLOORS.minCycleTime).toBe(30); const knoten = resolveRilsaSettings({ cycleTimeMax: 250 }, undefined, 'knotenpunkt'); expect(knoten.defaults.cycleTime.max).toBe(120); const kn = knoten.notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax'); expect(kn?.severity).toBe('fehler'); expect(kn?.message).toContain('höchstens 120 s'); expect(kn?.message).toContain('Umlaufzeiten über 120 s sind nach RiLSA nicht vorzusehen.'); const eng = resolveRilsaSettings({ cycleTimeMax: 250 }, undefined, 'einstreifig'); expect(eng.defaults.cycleTime.max).toBe(250); expect(eng.notes).toHaveLength(0); const zuLang = resolveRilsaSettings({ cycleTimeMax: 400 }, undefined, 'einstreifig'); expect(zuLang.defaults.cycleTime.max).toBe(300); expect( zuLang.notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')?.message, ).toContain('RSA'); const zuKurz = resolveRilsaSettings({ cycleTimeMin: 5 }, undefined, 'knotenpunkt'); expect(zuKurz.defaults.cycleTime.min).toBe(30); expect( zuKurz.notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeMin')?.message, ).toContain('Umlaufzeiten unter 30 s sind nicht schaltbar.'); // Im Plan und im Bericht: der einstreifige Plan mit 250 s bleibt ungekappt. const engPlan = buildSignalPlan(zweiKfzGruppen({}, 'einstreifig', { cycleTimeMax: 250 })); expect(engPlan.grenzen.cycleTime.max).toBe(250); expect(engPlan.notes.some((n) => n.code.startsWith('vorgabe-'))).toBe(false); const knotenPlan = buildSignalPlan(zweiKfzGruppen({}, 'knotenpunkt', { cycleTimeMax: 250 })); expect(knotenPlan.grenzen.cycleTime.max).toBe(120); expect(knotenPlan.notes.some((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toBe( true, ); }); it('C11: Webster L = 20 s, Y = 0,70 + 0,35 = 1,05 -> uebersaettigt, Ersatzwert 120 s; L = 40 s, Y = 0,85 -> 433 s, gekappt ("maximum"); HBS Y = 0,90 >= 0,85 -> uebersaettigt', () => { expect(RILSA_DEFAULTS.cycleTime.max).toBe(120); const ueber = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.7, 0.35] }); expect(ueber.totalFlowRatio).toBeCloseTo(1.05, 6); expect(ueber.bounded).toBe('uebersaettigt'); expect(ueber.cycleTime).toBe(120); expect(ueber.raw).toBe(Number.POSITIVE_INFINITY); const notiz = ueber.notes.find((n) => n.code === 'uebersaettigt'); expect(notiz?.severity).toBe('fehler'); expect(notiz?.message).toContain( 'Der Plan führt ersatzweise die Obergrenze von 120 s - das ist kein Bemessungsergebnis (nicht bemessbar, Ersatzwert).', ); const lang = computeCycleTime('webster', { lostTime: 40, criticalFlowRatios: [0.55, 0.3] }); // raw wird in cycle.ts auf zwei Nachkommastellen gefuehrt: 433,33 s. expect(lang.raw).toBeCloseTo((1.5 * 40 + 5) / 0.15, 2); expect(Math.round(lang.raw)).toBe(433); expect(lang.cycleTime).toBe(120); expect(lang.bounded).toBe('maximum'); expect(lang.notes.some((n) => n.code === 'uebersaettigt')).toBe(false); expect(RILSA_DEFAULTS.capacity.targetDegreeOfSaturation).toBe(0.85); // Seit 5.27.0 das obere Ende des Bandes der RiLSA (Abschnitt 2.6). expect(RILSA_DEFAULTS.capacity.maxDegreeOfSaturation).toBe(0.9); expect(computeCycleTime('hbs', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] }).bounded).toBe( 'uebersaettigt', ); expect( computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] }).bounded, ).not.toBe('uebersaettigt'); // Der Vermerk, den jede Ausgabe neben den Ersatzwert setzt - mit // Gedankenstrich wie in Hilfe und Unterlagen (Bindestrich in der // Konstante, Gedankenstrich in den Unterlagen ergab "120 s – … - Ersatzwert"). expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toBe('nicht bemessbar (übersättigt) – Ersatzwert'); const roh = createStandardIntersectionProject('Uebersaettigt', DATUM_C); const plan = buildSignalPlan({ ...roh, demands: roh.signalGroups .filter((g) => g.mode === 'kfz') .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), }); expect(plan.cycleResult?.bounded).toBe('uebersaettigt'); expect(umlaufzeitIstErsatzwert(plan)).toBe(true); expect(umlaufzeitIstErsatzwert(buildSignalPlan(roh))).toBe(false); }); });