lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain dokumentationZahlen.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { computeCapacity } from '@/domain/rilsa/capacity'; |
| 3 | import { computeDelay, computeFussRadDelay, serviceLevelFor } from '@/domain/rilsa/delay'; |
| 4 | import { computeIntergreen } from '@/domain/rilsa/intergreen'; |
| 5 | import { computeCycleTime } from '@/domain/rilsa/cycle'; |
| 6 | import { resolveRilsaSettings } from '@/domain/rilsa/settings'; |
| 7 | import { |
| 8 | ANLAGENART_GRENZEN, |
| 9 | ENGSTELLE_PRAXIS, |
| 10 | OEPNV_ANFAHRBESCHLEUNIGUNG, |
| 11 | RILSA_DEFAULTS, |
| 12 | RSA_EINSTREIFIG, |
| 13 | SAFETY_FLOORS, |
| 14 | VZUL_OBERGRENZE_LSA, |
| 15 | } from '@/domain/rilsa/constants'; |
| 16 | import { engstellenFolgen, engstellenPraxisHinweis } from '@/domain/rilsa/engstelle'; |
| 17 | import { furtFreigabebedarf } from '@/domain/rilsa/signalTimes'; |
| 18 | import { |
| 19 | UMLAUFZEIT_ERSATZWERT_VERMERK, |
| 20 | buildSignalPlan, |
| 21 | mindestfreigabezeitDerGruppe, |
| 22 | umlaufzeitIstErsatzwert, |
| 23 | } from '@/domain/plan/signalPlan'; |
| 24 | import { einstreifigBefunde, hoechstgeschwindigkeitBefunde } from '@/domain/validation/rules'; |
| 25 | import { |
| 26 | createConflict, |
| 27 | createEmptyProject, |
| 28 | createPhase, |
| 29 | createSignalGroup, |
| 30 | createStandardIntersectionProject, |
| 31 | } from '@/domain/model/factory'; |
| 32 | import { CURRENT_SCHEMA_VERSION, type Project, type SignalGroup } from '@/domain/model/project'; |
| 33 | import type { ClearingStream, EnteringStream, VehicleClass } from '@/domain/rilsa/types'; |
| 34 | |
| 35 | /* |
| 36 | * Die Zahlenbeispiele zu den Befunden: Kapazitaet, Wartezeit und |
| 37 | * Qualitaetsstufe, Zwischenzeiten, Furt, Schranken und ersetzte Eingaben. |
| 38 | * |
| 39 | * Grundsatz: Was gedruckt wird, muss sich aus den gedruckten Groessen |
| 40 | * nachrechnen lassen. Das gilt auch fuer die Zahlenbeispiele der |
| 41 | * Dokumentation - eine Zahl in einer Beschreibung, die sich aus keiner |
| 42 | * Rechnung des Programms ergibt, ist als Begruendung wertlos (siehe |
| 43 | * engstelle.test.ts: 125 s Zwischenzeit, die es nirgends gab). Dieser Test |
| 44 | * haelt die Beispiele an die Fachlogik. |
| 45 | * |
| 46 | * Erster Block (B1-B4): Kfz-Strom, ein Fahrstreifen geradeaus, tU = 90 s, |
| 47 | * tF = 30 s. Zweiter Block (A1-A4): die Zwischenzeitbeispiele zur Fassung |
| 48 | * 5.4.0. |
| 49 | */ |
| 50 | |
| 51 | const TU = 90; |
| 52 | const TF = 30; |
| 53 | const QS = RILSA_DEFAULTS.capacity.saturationFlow; |
| 54 | |
| 55 | function hbs(q: number) { |
| 56 | const kap = computeCapacity(TF, TU, QS, q, { verfahren: 'HBS' }); |
| 57 | const delay = computeDelay({ |
| 58 | cycleTime: TU, |
| 59 | greenTime: TF, |
| 60 | capacity: kap.capacity, |
| 61 | demand: q, |
| 62 | verfahren: 'HBS', |
| 63 | mode: 'kfz', |
| 64 | abflusszeit: kap.abflusszeit, |
| 65 | }); |
| 66 | return { kap, delay }; |
| 67 | } |
| 68 | |
| 69 | /** Der Altstand 5.3.5: 1800 Fz/h, HCM-Formel mit g = tF - unter dem Etikett HBS. */ |
| 70 | function altstand(q: number) { |
| 71 | const kap = computeCapacity(TF, TU, 1800, q, { verfahren: 'HCM' }); |
| 72 | const delay = computeDelay({ |
| 73 | cycleTime: TU, |
| 74 | greenTime: TF, |
| 75 | capacity: kap.capacity, |
| 76 | demand: q, |
| 77 | verfahren: 'HCM', |
| 78 | mode: 'kfz', |
| 79 | abflusszeit: kap.abflusszeit, |
| 80 | }); |
| 81 | return { kap, delay }; |
| 82 | } |
| 83 | |
| 84 | describe('Zahlenbeispiele zu Kapazitaet, Wartezeit und Qualitaetsstufe (B1-B4)', () => { |
| 85 | it('Kapazitaet: 1800 * 30 / 90 = 600 bisher, 2000 * 31 / 90 = 689 jetzt (+15 %)', () => { |
| 86 | expect(QS).toBe(2000); |
| 87 | expect(altstand(550).kap.capacity).toBe(600); |
| 88 | const { kap } = hbs(550); |
| 89 | expect(kap.abflusszeit).toBe(31); |
| 90 | expect(kap.capacity).toBe(689); |
| 91 | expect(Math.round(((689 - 600) / 600) * 100)).toBe(15); |
| 92 | }); |
| 93 | |
| 94 | it('q = 550: bisher x 0,92 -> 49,9 s, jetzt x 0,80 -> 43,3 s, beide Stufe C', () => { |
| 95 | const alt = altstand(550); |
| 96 | expect(alt.kap.degreeOfSaturation).toBeCloseTo(0.92, 2); |
| 97 | expect(alt.delay.averageDelay).toBeCloseTo(49.9, 1); |
| 98 | const neu = hbs(550); |
| 99 | expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.8, 2); |
| 100 | expect(neu.delay.averageDelay).toBeCloseTo(43.3, 1); |
| 101 | expect(neu.delay.serviceLevel?.grade).toBe('C'); |
| 102 | }); |
| 103 | |
| 104 | it('q = 655: bisher x 1,09 -> 94,2 s; jetzt x 0,95 -> 28,8 + 82,3 = 111,1 s, Stufe E', () => { |
| 105 | const alt = altstand(655); |
| 106 | expect(alt.kap.degreeOfSaturation).toBeCloseTo(1.09, 2); |
| 107 | expect(alt.delay.averageDelay).toBeCloseTo(94.2, 1); |
| 108 | const neu = hbs(655); |
| 109 | expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.95, 2); |
| 110 | expect(neu.delay.uniformDelay).toBeCloseTo(28.8, 1); |
| 111 | expect(neu.delay.incrementalDelay).toBeCloseTo(82.3, 1); |
| 112 | expect(neu.delay.averageDelay).toBeCloseTo(111.1, 1); |
| 113 | expect(neu.delay.serviceLevel?.grade).toBe('E'); |
| 114 | expect(neu.delay.serviceLevel?.kriterium).toBe('mittlere-wartezeit'); |
| 115 | }); |
| 116 | |
| 117 | it('q = 662: bisher 98,3 s; jetzt 120,0 s bei x 0,96 - Stufe E, nicht F', () => { |
| 118 | expect(altstand(662).delay.averageDelay).toBeCloseTo(98.3, 1); |
| 119 | const neu = hbs(662); |
| 120 | expect(neu.kap.degreeOfSaturation).toBeCloseTo(0.96, 2); |
| 121 | expect(neu.delay.averageDelay).toBeCloseTo(120.0, 1); |
| 122 | expect(neu.delay.serviceLevel?.grade).toBe('E'); |
| 123 | }); |
| 124 | |
| 125 | it('q = 700: bisher 122,1 s; jetzt x 1,02 -> 173,4 s, Stufe F wegen Ueberlastung', () => { |
| 126 | expect(altstand(700).delay.averageDelay).toBeCloseTo(122.1, 1); |
| 127 | const neu = hbs(700); |
| 128 | expect(neu.kap.degreeOfSaturation).toBeCloseTo(1.02, 2); |
| 129 | expect(neu.delay.averageDelay).toBeCloseTo(173.4, 1); |
| 130 | expect(neu.delay.serviceLevel?.grade).toBe('F'); |
| 131 | expect(neu.delay.serviceLevel?.kriterium).toBe('ueberlastung'); |
| 132 | }); |
| 133 | |
| 134 | it('gleiche Kapazitaet und gleicher Auslastungsgrad: HCM-Formel rund 53 s, HBS 111 s', () => { |
| 135 | // C = 689, q = 655 -> x = 0,95; nur die Formel wechselt. |
| 136 | const gemeinsam = { |
| 137 | cycleTime: TU, |
| 138 | greenTime: TF, |
| 139 | capacity: 689, |
| 140 | demand: 655, |
| 141 | mode: 'kfz' as const, |
| 142 | }; |
| 143 | const hcm = computeDelay({ ...gemeinsam, verfahren: 'HCM' }); |
| 144 | const hbsErgebnis = computeDelay({ ...gemeinsam, verfahren: 'HBS', abflusszeit: 31 }); |
| 145 | expect(hcm.averageDelay).toBeGreaterThanOrEqual(52.5); |
| 146 | expect(hcm.averageDelay).toBeLessThanOrEqual(53.5); |
| 147 | expect(hbsErgebnis.averageDelay).toBeCloseTo(111.1, 1); |
| 148 | // "etwa das Doppelte" bzw. umgekehrt "etwa die Haelfte" |
| 149 | expect(hbsErgebnis.averageDelay / hcm.averageDelay).toBeGreaterThan(1.9); |
| 150 | expect(hbsErgebnis.averageDelay / hcm.averageDelay).toBeLessThan(2.3); |
| 151 | }); |
| 152 | |
| 153 | it('Fussgaengerfurt tU 90 s, 12 s Freigabe: Sperrzeit 78 s -> Stufe E; HBS-S4-Beispiel 67 s -> D', () => { |
| 154 | const furt = computeFussRadDelay({ |
| 155 | cycleTime: 90, |
| 156 | sperrzeiten: [78], |
| 157 | verfahren: 'HBS', |
| 158 | mode: 'fuss', |
| 159 | }); |
| 160 | expect(furt.maximumDelay).toBe(78); |
| 161 | expect(furt.serviceLevel?.grade).toBe('E'); |
| 162 | expect(furt.serviceLevel?.kriterium).toBe('maximale-wartezeit'); |
| 163 | const beispiel = computeFussRadDelay({ |
| 164 | cycleTime: 110, |
| 165 | sperrzeiten: [67], |
| 166 | verfahren: 'HBS', |
| 167 | mode: 'fuss', |
| 168 | }); |
| 169 | expect(beispiel.serviceLevel?.grade).toBe('D'); |
| 170 | }); |
| 171 | |
| 172 | it('OePNV mit 25 s: Kfz-Tafel B, OePNV-Tafel C', () => { |
| 173 | expect(serviceLevelFor(25, 'HBS', { mode: 'kfz' })?.grade).toBe('B'); |
| 174 | expect(serviceLevelFor(25, 'HBS', { mode: 'oepnv' })?.grade).toBe('C'); |
| 175 | }); |
| 176 | |
| 177 | it('LISA+ K5: tW,G 37,9 s, tW,R 21,1 s, tW 59,0 s, Stufe D', () => { |
| 178 | const kap = computeCapacity(10, 90, 1992, 167, { verfahren: 'HBS' }); |
| 179 | expect(kap.capacity).toBe(243); |
| 180 | const delay = computeDelay({ |
| 181 | cycleTime: 90, |
| 182 | greenTime: 10, |
| 183 | capacity: 243, |
| 184 | demand: 167, |
| 185 | verfahren: 'HBS', |
| 186 | mode: 'kfz', |
| 187 | abflusszeit: 11, |
| 188 | }); |
| 189 | expect(delay.uniformDelay).toBeCloseTo(37.9, 1); |
| 190 | expect(delay.incrementalDelay).toBeCloseTo(21.1, 1); |
| 191 | expect(delay.averageDelay).toBeCloseTo(59.0, 1); |
| 192 | expect(delay.rechenweg.reststau).toBeCloseTo(1.43, 2); |
| 193 | expect(delay.serviceLevel?.grade).toBe('D'); |
| 194 | // tW,R ist die Differenz auf den ausgegebenen Ziffern, damit "37,9 + 21,1 = |
| 195 | // 59,0" im Ausdruck aufgeht. Unabhaengig gerundet ergaebe 37,85 + 21,17 = |
| 196 | // 59,02 die Zeile "37,9 + 21,2 = 59,0", die sich selbst widerlegt. |
| 197 | expect(delay.uniformDelay + delay.incrementalDelay).toBeCloseTo(delay.averageDelay, 6); |
| 198 | }); |
| 199 | |
| 200 | it('ohne Kapazitaet UND ohne Nachfrage keine Stufe; ohne Kapazitaet mit Nachfrage F', () => { |
| 201 | const leer = computeDelay({ |
| 202 | cycleTime: TU, |
| 203 | greenTime: TF, |
| 204 | capacity: 0, |
| 205 | demand: 0, |
| 206 | verfahren: 'HBS', |
| 207 | mode: 'kfz', |
| 208 | }); |
| 209 | expect(leer.serviceLevel).toBeNull(); |
| 210 | const ueberlastet = computeDelay({ |
| 211 | cycleTime: TU, |
| 212 | greenTime: TF, |
| 213 | capacity: 0, |
| 214 | demand: 100, |
| 215 | verfahren: 'HBS', |
| 216 | mode: 'kfz', |
| 217 | }); |
| 218 | expect(ueberlastet.serviceLevel?.grade).toBe('F'); |
| 219 | expect(ueberlastet.serviceLevel?.kriterium).toBe('ueberlastung'); |
| 220 | }); |
| 221 | }); |
| 222 | |
| 223 | /* |
| 224 | * Zahlenbeispiele zu den kritischen Befunden A1 bis A4 (Fassung 5.4.0). Die |
| 225 | * Fachtests dazu stehen in raeumgeschwindigkeitSchranken.test.ts, |
| 226 | * messgrundlage.test.ts und oepnvZwischenzeit.test.ts; hier geht es allein |
| 227 | * darum, dass die gedruckten Zahlen aus der Rechnung des Programms folgen. |
| 228 | */ |
| 229 | |
| 230 | const KFZ_EINFAHREND_10M: EnteringStream = { |
| 231 | mode: 'kfz', |
| 232 | movement: 'geradeaus', |
| 233 | enteringDistance: 10, |
| 234 | }; |
| 235 | |
| 236 | function radRaeumend(clearingDistance: number): ClearingStream { |
| 237 | return { mode: 'rad', movement: 'geradeaus', clearingDistance, vehicleClass: 'rad', vZul: 0 }; |
| 238 | } |
| 239 | |
| 240 | function bahnOhneHalt(extra: Partial<ClearingStream> = {}): ClearingStream { |
| 241 | // 25 m Grundraeumweg + 15 m Strassenbahn = 40 m Raeumweg. |
| 242 | return { |
| 243 | mode: 'oepnv', |
| 244 | movement: 'geradeaus', |
| 245 | clearingDistance: 25, |
| 246 | vehicleClass: 'strassenbahn', |
| 247 | vZul: 50, |
| 248 | ...extra, |
| 249 | }; |
| 250 | } |
| 251 | |
| 252 | describe('Zahlenbeispiele zu Raeum- und Einfahrzeiten (A1-A4)', () => { |
| 253 | it('A1: Rad raeumt 20 m, Kfz faehrt 10 m ein - bisher 4,1 -> 5 s, RiLSA 5,1 -> 6 s', () => { |
| 254 | // Der Altstand (5,0 m/s) laesst sich mit dem heutigen Programm nicht mehr |
| 255 | // rechnen - die Rad-Schranke kappt ihn. Die alte Zeile wird deshalb mit |
| 256 | // derselben Formel tz = tue + sr/vr - se/ve nachgestellt, die auch das |
| 257 | // Programm rechnet; der neue Wert kommt aus dem Programm selbst. |
| 258 | const te = 10 / RILSA_DEFAULTS.enteringSpeed.kfz; |
| 259 | const alt = 1 + 20 / 5 - te; |
| 260 | expect(alt).toBeCloseTo(4.1, 1); |
| 261 | expect(Math.ceil(alt)).toBe(5); |
| 262 | |
| 263 | const neu = computeIntergreen(radRaeumend(20), KFZ_EINFAHREND_10M); |
| 264 | expect(neu.clearingSpeed).toBe(4.0); |
| 265 | expect(neu.raw).toBeCloseTo(5.1, 1); |
| 266 | expect(neu.intergreen).toBe(6); |
| 267 | // "1 s zu frueh Freigabe" |
| 268 | expect(neu.intergreen - Math.ceil(alt)).toBe(1); |
| 269 | }); |
| 270 | |
| 271 | it('A1: bei 40 m Raeumweg waechst das Defizit auf 2 s (9 statt 11 s)', () => { |
| 272 | const te = 10 / RILSA_DEFAULTS.enteringSpeed.kfz; |
| 273 | expect(Math.ceil(1 + 40 / 5 - te)).toBe(9); |
| 274 | const neu = computeIntergreen(radRaeumend(40), KFZ_EINFAHREND_10M); |
| 275 | expect(neu.intergreen).toBe(11); |
| 276 | }); |
| 277 | |
| 278 | it('A1: 8 m/s als Rad-Ueberschreibung wird jetzt mit Fehler auf 4 m/s gekappt', () => { |
| 279 | const result = computeIntergreen( |
| 280 | { ...radRaeumend(20), clearingSpeedOverride: 8 }, |
| 281 | KFZ_EINFAHREND_10M, |
| 282 | ); |
| 283 | expect(SAFETY_FLOORS.maxClearingSpeedRad).toBe(4.0); |
| 284 | expect(result.clearingSpeed).toBe(4.0); |
| 285 | expect(result.notes.some((n) => n.severity === 'fehler')).toBe(true); |
| 286 | }); |
| 287 | |
| 288 | it('A3: 15 m/s aus den Vorgaben werden auf die 10-m/s-Schranke gekappt (Raeumzeit sonst um ein Drittel kuerzer)', () => { |
| 289 | const { defaults, notes } = resolveRilsaSettings({ clearingSpeedOepnv: 15 }); |
| 290 | expect(defaults.clearingSpeed.oepnv).toBe(SAFETY_FLOORS.maxClearingSpeedKfz); |
| 291 | expect(SAFETY_FLOORS.maxClearingSpeedKfz).toBe(10); |
| 292 | expect(notes.some((n) => n.severity === 'fehler')).toBe(true); |
| 293 | // 40 m: 40/15 = 2,67 s statt 40/10 = 4,0 s - ein Drittel weniger. |
| 294 | expect((40 / 10 - 40 / 15) / (40 / 10)).toBeCloseTo(1 / 3, 6); |
| 295 | }); |
| 296 | |
| 297 | it('A4: derselbe Wegfehler wirkt an der Furt fast zehnfach (1,2 statt 10 m/s)', () => { |
| 298 | expect(RILSA_DEFAULTS.clearingSpeed.fuss).toBe(1.2); |
| 299 | expect(RILSA_DEFAULTS.clearingSpeed.kfzStraight).toBe(10); |
| 300 | const faktor = RILSA_DEFAULTS.clearingSpeed.kfzStraight / RILSA_DEFAULTS.clearingSpeed.fuss; |
| 301 | expect(faktor).toBeGreaterThan(8); |
| 302 | expect(faktor).toBeLessThan(10); |
| 303 | }); |
| 304 | |
| 305 | 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', () => { |
| 306 | // Vor Einfahrzeitabzug, deshalb Einfahrweg 0. |
| 307 | const neu = computeIntergreen(bahnOhneHalt(), { |
| 308 | mode: 'kfz', |
| 309 | movement: 'geradeaus', |
| 310 | enteringDistance: 0, |
| 311 | }); |
| 312 | expect(neu.clearingPath).toBe(40); |
| 313 | expect(neu.crossingTime).toBe(5); |
| 314 | expect(neu.clearingSpeed).toBeCloseTo(50 / 3.6, 6); |
| 315 | expect(neu.raw).toBeCloseTo(7.9, 1); |
| 316 | expect(neu.intergreen).toBe(8); |
| 317 | // Altstand: Ueberfahrzeit 0 s, feste 10 m/s. |
| 318 | const alt = 0 + 40 / 10; |
| 319 | expect(alt).toBe(4.0); |
| 320 | // "rund 4 s zu wenig" |
| 321 | expect(Math.round(neu.raw - alt)).toBe(4); |
| 322 | }); |
| 323 | |
| 324 | it('A2: dieselbe Bahn mit Halt vor dem Knotenpunkt - 0 + Wurzel(2 * 40 / 1,0) = 8,9 s', () => { |
| 325 | const halt = computeIntergreen(bahnOhneHalt({ haltVorKnoten: true }), { |
| 326 | mode: 'kfz', |
| 327 | movement: 'geradeaus', |
| 328 | enteringDistance: 0, |
| 329 | }); |
| 330 | expect(OEPNV_ANFAHRBESCHLEUNIGUNG.strassenbahn).toBe(1.0); |
| 331 | expect(halt.crossingTime).toBe(0); |
| 332 | expect(halt.raeumansatz).toBe('anfahren'); |
| 333 | expect(halt.raw).toBeCloseTo(8.9, 1); |
| 334 | }); |
| 335 | }); |
| 336 | |
| 337 | /* |
| 338 | * Zahlenbeispiele zu den Befunden C1 bis C6: Pruefungen, die fehlten - zur |
| 339 | * Furt, zu den Schranken je Anlagenart, zu den Signalzeiten und zu den |
| 340 | * Randbedingungen der Anordnung. Die Fachtests stehen in |
| 341 | * fehlendePruefungen.test.ts; hier geht es allein darum, dass jede gedruckte |
| 342 | * Zahl aus der Rechnung des Programms folgt - und dass Regelwerkswert und |
| 343 | * Praxiswert je als solcher gefuehrt sind. |
| 344 | */ |
| 345 | |
| 346 | const DATUM_C = new Date('2026-01-01T00:00:00Z'); |
| 347 | |
| 348 | /** Ein Kfz-Strom und eine Furt, die ihn quert; Furtlaenge = Raeumweg der Furt. */ |
| 349 | function furtProjekt(furtlaenge: number, furt: Partial<SignalGroup> = {}): Project { |
| 350 | const basis = createEmptyProject('Furt', DATUM_C); |
| 351 | const k = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); |
| 352 | const f: SignalGroup = { ...createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 }), ...furt }; |
| 353 | const phaseK = createPhase('Kfz', [k.id]); |
| 354 | const phaseF = createPhase('Fuss', [f.id]); |
| 355 | return { |
| 356 | ...basis, |
| 357 | signalGroups: [k, f], |
| 358 | conflicts: [ |
| 359 | { ...createConflict(f.id, k.id), clearingDistance: furtlaenge, enteringDistance: 3 }, |
| 360 | { ...createConflict(k.id, f.id), clearingDistance: 15, enteringDistance: 5 }, |
| 361 | ], |
| 362 | phases: [phaseK, phaseF], |
| 363 | program: { ...basis.program, phaseOrder: [phaseK.id, phaseF.id] }, |
| 364 | }; |
| 365 | } |
| 366 | |
| 367 | /** Einstreifige Verkehrsfuehrung, zwei Richtungen; die Engstelle ist der Raeumweg. */ |
| 368 | function engstellenProjekt(laenge: number, vZul = 50): Project { |
| 369 | const basis = createEmptyProject('Arbeitsstelle', DATUM_C, 'einstreifig'); |
| 370 | const a1 = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0, vZul }); |
| 371 | const a2 = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1, vZul }); |
| 372 | const p1 = createPhase('Richtung 1', [a1.id]); |
| 373 | const p2 = createPhase('Richtung 2', [a2.id]); |
| 374 | return { |
| 375 | ...basis, |
| 376 | intersection: { ...basis.intersection, vZul }, |
| 377 | signalGroups: [a1, a2], |
| 378 | conflicts: [ |
| 379 | { ...createConflict(a1.id, a2.id), clearingDistance: laenge, enteringDistance: 0 }, |
| 380 | { ...createConflict(a2.id, a1.id), clearingDistance: laenge, enteringDistance: 0 }, |
| 381 | ], |
| 382 | phases: [p1, p2], |
| 383 | program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, |
| 384 | }; |
| 385 | } |
| 386 | |
| 387 | function furtGruppe(project: Project): SignalGroup { |
| 388 | return project.signalGroups.find((g) => g.mode === 'fuss')!; |
| 389 | } |
| 390 | |
| 391 | describe('Zahlenbeispiele zu Furt, Geschwindigkeit, Engstelle und Umlaufzeit (C1-C6)', () => { |
| 392 | 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)', () => { |
| 393 | expect(RILSA_DEFAULTS.minGreen.fuss).toBe(5); |
| 394 | expect(RILSA_DEFAULTS.clearingSpeed.fuss).toBe(1.2); |
| 395 | const halb = furtFreigabebedarf(14, 1.2, false); |
| 396 | expect(halb.furtweg).toBe(7); |
| 397 | expect(halb.zeitbedarf).toBeCloseTo(5.83, 2); |
| 398 | |
| 399 | const projekt = furtProjekt(14); |
| 400 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 401 | expect(mfz.regelwert).toBe(5); |
| 402 | expect(mfz.wert).toBe(6); |
| 403 | expect(mfz.massgebend).toBe('furt'); |
| 404 | // Der Hinweis nennt den Rechenweg, wie er in den Unterlagen steht - Furtweg |
| 405 | // mit zwei, Zeitbedarf mit drei Nachkommastellen. |
| 406 | const notiz = buildSignalPlan(projekt).notes.find( |
| 407 | (n) => n.code === 'mindestfreigabezeit-aus-furtlaenge', |
| 408 | ); |
| 409 | expect(notiz?.message).toContain('7,00 m / 1,2 m/s = 5,833 s, aufgerundet 6 s'); |
| 410 | }); |
| 411 | |
| 412 | 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', () => { |
| 413 | const projekt = furtProjekt(12.01); |
| 414 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 415 | expect(mfz.furt?.zeitbedarf).toBeCloseTo(5.004, 3); |
| 416 | expect(mfz.furt?.zeitbedarfGerundet).toBe(6); |
| 417 | expect(mfz.wert).toBe(6); |
| 418 | const notiz = buildSignalPlan(projekt).notes.find( |
| 419 | (n) => n.code === 'mindestfreigabezeit-aus-furtlaenge', |
| 420 | ); |
| 421 | expect(notiz?.message).toContain('6,01 m / 1,2 m/s = 5,004 s, aufgerundet 6 s'); |
| 422 | }); |
| 423 | |
| 424 | it('C1: dieselbe Furt mit Zusatzeinrichtung fuer Blinde und Sehbehinderte - ganze Furt 14 m / 1,2 m/s = 11,67 s -> 12 s', () => { |
| 425 | const projekt = furtProjekt(14, { blindenzusatz: true }); |
| 426 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 427 | expect(mfz.furt?.ganzeFurt).toBe(true); |
| 428 | expect(mfz.furt?.furtweg).toBe(14); |
| 429 | expect(mfz.furt?.zeitbedarf).toBeCloseTo(11.67, 2); |
| 430 | expect(mfz.wert).toBe(12); |
| 431 | }); |
| 432 | |
| 433 | it('C1: erhoehter Zeitbedarf rechnet mit 1,0 m/s - 7 m / 1,0 m/s = 7 s', () => { |
| 434 | expect(RILSA_DEFAULTS.clearingSpeed.fussReducedMobility).toBe(1.0); |
| 435 | const projekt = furtProjekt(14, { reducedMobility: true }); |
| 436 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 437 | expect(mfz.furt?.vr).toBe(1.0); |
| 438 | expect(mfz.wert).toBe(7); |
| 439 | }); |
| 440 | |
| 441 | it('C1: Furt 20 m - halbe Furt 10 m / 1,2 m/s = 8,33 s -> 9 s; die Fussgaengerphase erhaelt mindestens 9 s', () => { |
| 442 | const projekt = furtProjekt(20); |
| 443 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 444 | expect(mfz.furt?.zeitbedarf).toBeCloseTo(8.33, 2); |
| 445 | expect(mfz.wert).toBe(9); |
| 446 | // "Umlauf kann wachsen": Verteilung und Pruefung ziehen dieselbe Untergrenze; |
| 447 | // die Phase der Furt erhaelt mindestens die 9 s, nicht mehr 5 s. |
| 448 | const plan = buildSignalPlan(projekt); |
| 449 | expect(plan.phases.find((p) => p.name === 'Fuss')!.duration).toBeGreaterThanOrEqual(9); |
| 450 | expect(plan.groups.find((g) => g.mode === 'fuss')!.times.minGreen).toBe(9); |
| 451 | // Eine von Hand auf 5 s gesetzte Fussgaengerphase - bis dahin stillschweigend |
| 452 | // zulaessig - wird auf die 9 s angehoben und als zu kurz gemeldet. |
| 453 | const vonHand: Project = { |
| 454 | ...projekt, |
| 455 | phases: projekt.phases.map((p) => (p.name === 'Fuss' ? { ...p, manualGreen: 5 } : p)), |
| 456 | }; |
| 457 | const planVonHand = buildSignalPlan(vonHand); |
| 458 | expect(planVonHand.phases.find((p) => p.name === 'Fuss')!.duration).toBe(9); |
| 459 | expect(planVonHand.notes.some((n) => n.code === 'phase-freigabezeit-zu-kurz')).toBe(true); |
| 460 | // Zum Vergleich: dieselbe Furt mit 12 m bleibt bei 5 s. |
| 461 | expect( |
| 462 | buildSignalPlan(furtProjekt(12)).groups.find((g) => g.mode === 'fuss')!.times.minGreen, |
| 463 | ).toBe(5); |
| 464 | }); |
| 465 | |
| 466 | it('C1: Furt 12 m - halbe Furt 6 m / 1,2 m/s = 5,0 s, der Regelwert bleibt; Beispielknotenpunkt unveraendert', () => { |
| 467 | const projekt = furtProjekt(12); |
| 468 | const mfz = mindestfreigabezeitDerGruppe(projekt, furtGruppe(projekt), RILSA_DEFAULTS); |
| 469 | expect(mfz.wert).toBe(5); |
| 470 | expect(mfz.massgebend).toBe('regelwert'); |
| 471 | const plan = buildSignalPlan(createStandardIntersectionProject('Vorlage', DATUM_C)); |
| 472 | for (const g of plan.groups.filter((g) => g.mode === 'fuss')) expect(g.times.minGreen).toBe(5); |
| 473 | }); |
| 474 | |
| 475 | it('C1: Schemastand 10 auf 11; ein fehlendes Feld heisst "ohne Zusatz" (halbe Furt)', () => { |
| 476 | // Der Schemastand ist seither weiter gestiegen (12 seit Fassung 5.5.0 mit |
| 477 | // den Fragen E1/E2, 13 seit Fassung 5.6.0 mit dem Lastzuganteil); die |
| 478 | // Aussage von C1 bleibt davon unberuehrt - das Feld blindenzusatz kam mit |
| 479 | // der 11. |
| 480 | expect(CURRENT_SCHEMA_VERSION).toBe(19); |
| 481 | const projekt = furtProjekt(14); |
| 482 | const ohneFeld: SignalGroup = { ...furtGruppe(projekt) }; |
| 483 | delete (ohneFeld as { blindenzusatz?: boolean }).blindenzusatz; |
| 484 | const mfz = mindestfreigabezeitDerGruppe( |
| 485 | { |
| 486 | ...projekt, |
| 487 | signalGroups: projekt.signalGroups.map((g) => (g.mode === 'fuss' ? ohneFeld : g)), |
| 488 | }, |
| 489 | ohneFeld, |
| 490 | RILSA_DEFAULTS, |
| 491 | ); |
| 492 | expect(mfz.furt?.ganzeFurt).toBe(false); |
| 493 | expect(mfz.wert).toBe(6); |
| 494 | }); |
| 495 | |
| 496 | it('C2/C6: 70 km/h nach VwV-StVO, 50 km/h nach RSA 21 - 80 km/h am Knotenpunkt: Warnung, bisher keine', () => { |
| 497 | expect(VZUL_OBERGRENZE_LSA).toBe(70); |
| 498 | expect(RSA_EINSTREIFIG.vZulRegel).toBe(50); |
| 499 | const knoten = createStandardIntersectionProject('vZul', DATUM_C); |
| 500 | const mit80: Project = { |
| 501 | ...knoten, |
| 502 | signalGroups: knoten.signalGroups.map((g) => (g.name === 'K1' ? { ...g, vZul: 80 } : g)), |
| 503 | }; |
| 504 | const befunde = hoechstgeschwindigkeitBefunde(mit80); |
| 505 | expect( |
| 506 | befunde.filter((f) => f.rule === 'signalgruppen.vzul-ueber-70' && f.severity === 'warnung'), |
| 507 | ).toHaveLength(1); |
| 508 | // Am Knotenpunkt keine RSA-Regel; 70 km/h selbst ist noch kein Befund. |
| 509 | expect(befunde.filter((f) => f.rule === 'einstreifig.vzul')).toEqual([]); |
| 510 | expect(hoechstgeschwindigkeitBefunde(knoten)).toEqual([]); |
| 511 | }); |
| 512 | |
| 513 | it('C6: einstreifig 60 km/h - Hinweis je Kfz-Gruppe und fuer den Anlagenbereich; 80 km/h - Warnung, dazu C2', () => { |
| 514 | const hinweise = hoechstgeschwindigkeitBefunde(engstellenProjekt(200, 60)); |
| 515 | const rsa60 = hinweise.filter((f) => f.rule === 'einstreifig.vzul'); |
| 516 | // zwei Kfz-Gruppen + Anlagenbereich |
| 517 | expect(rsa60).toHaveLength(3); |
| 518 | expect(rsa60.every((f) => f.severity === 'hinweis')).toBe(true); |
| 519 | expect(hinweise.filter((f) => f.rule.endsWith('vzul-ueber-70'))).toEqual([]); |
| 520 | |
| 521 | const warnungen = hoechstgeschwindigkeitBefunde(engstellenProjekt(200, 80)); |
| 522 | expect( |
| 523 | warnungen.filter((f) => f.rule === 'einstreifig.vzul' && f.severity === 'warnung'), |
| 524 | ).toHaveLength(3); |
| 525 | expect(warnungen.filter((f) => f.rule === 'signalgruppen.vzul-ueber-70')).toHaveLength(2); |
| 526 | expect(warnungen.filter((f) => f.rule === 'projekt.vzul-ueber-70')).toHaveLength(1); |
| 527 | }); |
| 528 | |
| 529 | it('C3: 50 m und 500 Kfz/h - Anordnungskriterien einmal je Projekt, kurze Engstelle bis 50 m, Typklasse', () => { |
| 530 | expect(RSA_EINSTREIFIG.kurzeEngstelleBis).toBe(50); |
| 531 | expect(RSA_EINSTREIFIG.verkehrsstaerkeRichtwert).toBe(500); |
| 532 | const kurz = einstreifigBefunde(engstellenProjekt(50)); |
| 533 | expect(kurz.filter((f) => f.rule === 'einstreifig.rsa-anordnungskriterien')).toHaveLength(1); |
| 534 | expect(kurz.filter((f) => f.rule === 'einstreifig.kurze-engstelle')).toHaveLength(1); |
| 535 | expect(kurz.filter((f) => f.rule === 'einstreifig.ztv-typklasse')).toHaveLength(1); |
| 536 | expect(kurz.every((f) => f.severity === 'hinweis')).toBe(true); |
| 537 | expect( |
| 538 | einstreifigBefunde(engstellenProjekt(51)).some( |
| 539 | (f) => f.rule === 'einstreifig.kurze-engstelle', |
| 540 | ), |
| 541 | ).toBe(false); |
| 542 | // Am Knotenpunkt keine dieser Regeln. |
| 543 | expect(einstreifigBefunde(createStandardIntersectionProject('Knoten', DATUM_C))).toEqual([]); |
| 544 | }); |
| 545 | |
| 546 | it('C4: 120 s von Hand am Knotenpunkt - "ausserhalb des ueblichen Bereichs von 30 bis 90 s"', () => { |
| 547 | // Seit 5.27.0 der Richtwert der RiLSA (Abschnitt 2.6) statt der 60 s, |
| 548 | // die dieses Programm gesetzt hatte. |
| 549 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMin).toBe(30); |
| 550 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMax).toBe(90); |
| 551 | const roh = createStandardIntersectionProject('Manuell', DATUM_C); |
| 552 | const manuell: Project = { |
| 553 | ...roh, |
| 554 | program: { ...roh.program, method: 'manuell', manualCycleTime: 120 }, |
| 555 | }; |
| 556 | const treffer = buildSignalPlan(manuell).notes.filter( |
| 557 | (n) => n.code === 'umlaufzeit-ausserhalb-regelbereich', |
| 558 | ); |
| 559 | expect(treffer).toHaveLength(1); |
| 560 | expect(treffer[0]!.severity).toBe('hinweis'); |
| 561 | expect(treffer[0]!.message).toBe( |
| 562 | 'Die Umlaufzeit 120 s liegt außerhalb des üblichen Bereichs von 30 bis 90 s.', |
| 563 | ); |
| 564 | // 150 s bei einstreifiger Fuehrung liegen im Regelbereich (60-180 s). |
| 565 | expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.recommendedMax).toBe(180); |
| 566 | const eng = engstellenProjekt(300); |
| 567 | const eng150: Project = { |
| 568 | ...eng, |
| 569 | program: { ...eng.program, method: 'manuell', manualCycleTime: 150 }, |
| 570 | }; |
| 571 | expect( |
| 572 | buildSignalPlan(eng150).notes.some((n) => n.code === 'umlaufzeit-ausserhalb-regelbereich'), |
| 573 | ).toBe(false); |
| 574 | }); |
| 575 | |
| 576 | it('C5: 400 m Hinweis, 600 m Warnung (Behoerdenpraxis); die Wartezeitschranke schlug bisher erst ab 835 m an', () => { |
| 577 | expect(ENGSTELLE_PRAXIS).toEqual({ hinweisAb: 400, warnungAb: 600 }); |
| 578 | expect(engstellenPraxisHinweis(399)).toBeNull(); |
| 579 | expect(engstellenPraxisHinweis(400)?.stufe).toBe('hinweis'); |
| 580 | expect(engstellenPraxisHinweis(600)?.stufe).toBe('warnung'); |
| 581 | /* |
| 582 | * Vor C5 gab es zwischen 400 m und der Wartezeitschranke keine Meldung. |
| 583 | * Wo diese Schranke greift, hat sich mit 5.27.0 verschoben: Das |
| 584 | * Engstellenverfahren des Abschnitts 5.2 raeumt bei zulaessig 50 km/h mit |
| 585 | * 40 km/h statt mit 10 m/s, und der Raeumweg traegt keine Fahrzeuglaenge |
| 586 | * mehr. Aus rund 835 m sind rund 923 m geworden - 5 s Freigabe + 2 x |
| 587 | * Zwischenzeit; bei 922 m ist tz = 87 s (5 + 174 = 179 s), bei 923 m |
| 588 | * tz = 88 s (5 + 176 = 181 s). Der Befund C5 traegt dadurch weiter: Ohne |
| 589 | * die Behoerdenpraxis waere die erste Meldung noch spaeter gekommen. |
| 590 | */ |
| 591 | expect(ANLAGENART_GRENZEN.einstreifig.warteZeitWarnung).toBe(180); |
| 592 | expect(engstellenFolgen(922, 50).zwischenzeit).toBe(87); |
| 593 | expect(engstellenFolgen(922, 50).wartezeit).toBe(179); |
| 594 | expect(engstellenFolgen(922, 50).stufe).toBe('hinweis'); |
| 595 | expect(engstellenFolgen(923, 50).zwischenzeit).toBe(88); |
| 596 | expect(engstellenFolgen(923, 50).stufe).toBe('warnung'); |
| 597 | // Und das Beispiel mit 400 m: 40 s Zwischenzeit, 120 s Umlauf bei 20 s Freigabe. |
| 598 | expect(engstellenFolgen(400, 50).zwischenzeit).toBe(40); |
| 599 | expect(engstellenFolgen(400, 50).praxisHinweis?.stufe).toBe('hinweis'); |
| 600 | expect(engstellenFolgen(700, 50).praxisHinweis?.stufe).toBe('warnung'); |
| 601 | // Im Bericht: Referenz nennt die Einordnung. |
| 602 | const [befund] = einstreifigBefunde(engstellenProjekt(600)).filter( |
| 603 | (f) => f.rule === 'einstreifig.laenge-behoerdenpraxis', |
| 604 | ); |
| 605 | expect(befund?.severity).toBe('warnung'); |
| 606 | expect(befund?.reference).toBe('Behördenpraxis (z. B. Hessen Mobil), kein Regelwerkswert'); |
| 607 | }); |
| 608 | }); |
| 609 | |
| 610 | /* |
| 611 | * Zahlenbeispiele zu den Befunden C7 bis C12: Exportsperre und stille |
| 612 | * Ersetzungen - an Zwischenzeit, Signalzeiten, Umlaufzeit, Vorgabewerten und |
| 613 | * Laengenzuschlag. Die Fachtests stehen in stilleErsetzungen.test.ts; hier geht |
| 614 | * es allein darum, dass jede gedruckte Zahl und jeder zitierte Wortlaut aus der |
| 615 | * Rechnung des Programms folgt. C7 und C12 (Ausgabe-Ansicht, Assistent) haben |
| 616 | * keine Zahl im Fachkern und stehen deshalb nicht hier. |
| 617 | */ |
| 618 | |
| 619 | /** Zwei Kfz-Gruppen in zwei Phasen; die zweite Gruppe laesst sich anpassen. */ |
| 620 | function zweiKfzGruppen( |
| 621 | zweite: Partial<SignalGroup> = {}, |
| 622 | anlagenart: 'knotenpunkt' | 'einstreifig' = 'knotenpunkt', |
| 623 | rilsa: Project['settings']['rilsa'] = {}, |
| 624 | ): Project { |
| 625 | const basis = createEmptyProject('Zwei Gruppen', DATUM_C, anlagenart); |
| 626 | const a = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 }); |
| 627 | const b: SignalGroup = { ...createSignalGroup({ name: 'K2', mode: 'kfz', index: 1 }), ...zweite }; |
| 628 | const phase1 = createPhase('Phase 1', [a.id]); |
| 629 | const phase2 = createPhase('Phase 2', [b.id]); |
| 630 | return { |
| 631 | ...basis, |
| 632 | signalGroups: [a, b], |
| 633 | conflicts: [ |
| 634 | { ...createConflict(a.id, b.id), clearingDistance: 22, enteringDistance: 8 }, |
| 635 | { ...createConflict(b.id, a.id), clearingDistance: 22, enteringDistance: 8 }, |
| 636 | ], |
| 637 | phases: [phase1, phase2], |
| 638 | program: { ...basis.program, phaseOrder: [phase1.id, phase2.id] }, |
| 639 | settings: { ...basis.settings, rilsa }, |
| 640 | }; |
| 641 | } |
| 642 | |
| 643 | describe('Zahlenbeispiele zu gekappten und ersetzten Eingaben (C7-C12)', () => { |
| 644 | 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', () => { |
| 645 | const raeumend: ClearingStream = { |
| 646 | mode: 'kfz', |
| 647 | movement: 'geradeaus', |
| 648 | clearingDistance: 22, |
| 649 | vehicleClass: 'traktor' as VehicleClass, |
| 650 | vZul: 50, |
| 651 | }; |
| 652 | const einfahrend: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 8 }; |
| 653 | |
| 654 | // Der Altstand (0 m) laesst sich mit dem heutigen Programm nicht mehr |
| 655 | // rechnen; die alte Zeile wird mit derselben Formel nachgestellt. |
| 656 | const te = 8 / RILSA_DEFAULTS.enteringSpeed.kfz; |
| 657 | expect(te).toBeCloseTo(0.72, 2); |
| 658 | const alt = 3 + 22 / 10 - te; |
| 659 | expect(alt).toBeCloseTo(4.48, 2); |
| 660 | expect(Math.ceil(alt)).toBe(5); |
| 661 | |
| 662 | const neu = computeIntergreen(raeumend, einfahrend); |
| 663 | expect(neu.vehicleLength).toBe(SAFETY_FLOORS.minVehicleLengthKfz); |
| 664 | expect(neu.vehicleLength).toBe(6); |
| 665 | expect(neu.clearingPath).toBe(28); |
| 666 | expect(neu.crossingTime).toBe(3); |
| 667 | expect(neu.raw).toBeCloseTo(5.08, 2); |
| 668 | expect(neu.intergreen).toBe(6); |
| 669 | expect( |
| 670 | neu.notes.some((n) => n.code === 'fahrzeugart-unbekannt' && n.severity === 'fehler'), |
| 671 | ).toBe(true); |
| 672 | // "wie beim Pkw" |
| 673 | const pkw = computeIntergreen({ ...raeumend, vehicleClass: 'pkw' }, einfahrend); |
| 674 | expect(pkw.intergreen).toBe(6); |
| 675 | expect(pkw.raw).toBeCloseTo(neu.raw, 6); |
| 676 | // Fussgaenger: 0 m ohne Meldung. |
| 677 | const fuss = computeIntergreen( |
| 678 | { |
| 679 | mode: 'fuss', |
| 680 | movement: 'querung', |
| 681 | clearingDistance: 12, |
| 682 | vehicleClass: 'traktor' as VehicleClass, |
| 683 | vZul: 0, |
| 684 | }, |
| 685 | einfahrend, |
| 686 | ); |
| 687 | expect(fuss.vehicleLength).toBe(0); |
| 688 | expect(fuss.notes.some((n) => n.code === 'fahrzeugart-unbekannt')).toBe(false); |
| 689 | }); |
| 690 | |
| 691 | it('C9: 3 s eingetragen - "sie unterschreitet den Regelwert der Verkehrsart von 5 s. Massgebend bleibt 5 s; die Vorgabe hat keine Wirkung."', () => { |
| 692 | const projekt = zweiKfzGruppen({ minGreenOverride: 3 }); |
| 693 | const plan = buildSignalPlan(projekt); |
| 694 | const k2 = projekt.signalGroups.find((g) => g.name === 'K2')!; |
| 695 | expect(plan.groups.find((g) => g.groupId === k2.id)!.times.minGreen).toBe(5); |
| 696 | const [notiz] = plan.notes.filter( |
| 697 | (n) => n.code === 'mindestfreigabezeit-vorgabe-unterschritten', |
| 698 | ); |
| 699 | expect(notiz?.severity).toBe('warnung'); |
| 700 | expect(notiz?.message).toBe( |
| 701 | 'Für die Signalgruppe "K2" ist eine Mindestfreigabezeit von 3 s eingetragen; sie ' + |
| 702 | 'unterschreitet den Regelwert der Verkehrsart von 5 s. Maßgebend bleibt 5 s; die Vorgabe ' + |
| 703 | 'hat keine Wirkung.', |
| 704 | ); |
| 705 | // 5,5 s an einer 14-m-Furt (Furtbedarf 6 s): "unterschreitet den Furtbedarf von 6 s". |
| 706 | const furt = furtProjekt(14, { minGreenOverride: 5.5 }); |
| 707 | expect(mindestfreigabezeitDerGruppe(furt, furtGruppe(furt), RILSA_DEFAULTS).wert).toBe(6); |
| 708 | const [furtNotiz] = buildSignalPlan(furt).notes.filter( |
| 709 | (n) => n.code === 'mindestfreigabezeit-vorgabe-unterschritten', |
| 710 | ); |
| 711 | expect(furtNotiz?.message).toContain('5,5 s eingetragen'); |
| 712 | expect(furtNotiz?.message).toContain('unterschreitet den Furtbedarf von 6 s'); |
| 713 | expect(furtNotiz?.message).toContain('Maßgebend bleibt 6 s'); |
| 714 | }); |
| 715 | |
| 716 | 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")', () => { |
| 717 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.max).toBe(120); |
| 718 | expect(ANLAGENART_GRENZEN.fussgaengerschutzanlage.cycleTime.max).toBe(120); |
| 719 | expect(ANLAGENART_GRENZEN.einstreifig.cycleTime.max).toBe(300); |
| 720 | expect(SAFETY_FLOORS.minCycleTime).toBe(30); |
| 721 | |
| 722 | const knoten = resolveRilsaSettings({ cycleTimeMax: 250 }, undefined, 'knotenpunkt'); |
| 723 | expect(knoten.defaults.cycleTime.max).toBe(120); |
| 724 | const kn = knoten.notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax'); |
| 725 | expect(kn?.severity).toBe('fehler'); |
| 726 | expect(kn?.message).toContain('höchstens 120 s'); |
| 727 | expect(kn?.message).toContain('Umlaufzeiten über 120 s sind nach RiLSA nicht vorzusehen.'); |
| 728 | |
| 729 | const eng = resolveRilsaSettings({ cycleTimeMax: 250 }, undefined, 'einstreifig'); |
| 730 | expect(eng.defaults.cycleTime.max).toBe(250); |
| 731 | expect(eng.notes).toHaveLength(0); |
| 732 | |
| 733 | const zuLang = resolveRilsaSettings({ cycleTimeMax: 400 }, undefined, 'einstreifig'); |
| 734 | expect(zuLang.defaults.cycleTime.max).toBe(300); |
| 735 | expect( |
| 736 | zuLang.notes.find((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')?.message, |
| 737 | ).toContain('RSA'); |
| 738 | |
| 739 | const zuKurz = resolveRilsaSettings({ cycleTimeMin: 5 }, undefined, 'knotenpunkt'); |
| 740 | expect(zuKurz.defaults.cycleTime.min).toBe(30); |
| 741 | expect( |
| 742 | zuKurz.notes.find((n) => n.code === 'vorgabe-unterschritten:cycleTimeMin')?.message, |
| 743 | ).toContain('Umlaufzeiten unter 30 s sind nicht schaltbar.'); |
| 744 | |
| 745 | // Im Plan und im Bericht: der einstreifige Plan mit 250 s bleibt ungekappt. |
| 746 | const engPlan = buildSignalPlan(zweiKfzGruppen({}, 'einstreifig', { cycleTimeMax: 250 })); |
| 747 | expect(engPlan.grenzen.cycleTime.max).toBe(250); |
| 748 | expect(engPlan.notes.some((n) => n.code.startsWith('vorgabe-'))).toBe(false); |
| 749 | const knotenPlan = buildSignalPlan(zweiKfzGruppen({}, 'knotenpunkt', { cycleTimeMax: 250 })); |
| 750 | expect(knotenPlan.grenzen.cycleTime.max).toBe(120); |
| 751 | expect(knotenPlan.notes.some((n) => n.code === 'vorgabe-ueberschritten:cycleTimeMax')).toBe( |
| 752 | true, |
| 753 | ); |
| 754 | }); |
| 755 | |
| 756 | 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', () => { |
| 757 | expect(RILSA_DEFAULTS.cycleTime.max).toBe(120); |
| 758 | const ueber = computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.7, 0.35] }); |
| 759 | expect(ueber.totalFlowRatio).toBeCloseTo(1.05, 6); |
| 760 | expect(ueber.bounded).toBe('uebersaettigt'); |
| 761 | expect(ueber.cycleTime).toBe(120); |
| 762 | expect(ueber.raw).toBe(Number.POSITIVE_INFINITY); |
| 763 | const notiz = ueber.notes.find((n) => n.code === 'uebersaettigt'); |
| 764 | expect(notiz?.severity).toBe('fehler'); |
| 765 | expect(notiz?.message).toContain( |
| 766 | 'Der Plan führt ersatzweise die Obergrenze von 120 s - das ist kein Bemessungsergebnis (nicht bemessbar, Ersatzwert).', |
| 767 | ); |
| 768 | |
| 769 | const lang = computeCycleTime('webster', { lostTime: 40, criticalFlowRatios: [0.55, 0.3] }); |
| 770 | // raw wird in cycle.ts auf zwei Nachkommastellen gefuehrt: 433,33 s. |
| 771 | expect(lang.raw).toBeCloseTo((1.5 * 40 + 5) / 0.15, 2); |
| 772 | expect(Math.round(lang.raw)).toBe(433); |
| 773 | expect(lang.cycleTime).toBe(120); |
| 774 | expect(lang.bounded).toBe('maximum'); |
| 775 | expect(lang.notes.some((n) => n.code === 'uebersaettigt')).toBe(false); |
| 776 | |
| 777 | expect(RILSA_DEFAULTS.capacity.targetDegreeOfSaturation).toBe(0.85); |
| 778 | // Seit 5.27.0 das obere Ende des Bandes der RiLSA (Abschnitt 2.6). |
| 779 | expect(RILSA_DEFAULTS.capacity.maxDegreeOfSaturation).toBe(0.9); |
| 780 | expect(computeCycleTime('hbs', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] }).bounded).toBe( |
| 781 | 'uebersaettigt', |
| 782 | ); |
| 783 | expect( |
| 784 | computeCycleTime('webster', { lostTime: 20, criticalFlowRatios: [0.5, 0.4] }).bounded, |
| 785 | ).not.toBe('uebersaettigt'); |
| 786 | |
| 787 | // Der Vermerk, den jede Ausgabe neben den Ersatzwert setzt - mit |
| 788 | // Gedankenstrich wie in Hilfe und Unterlagen (Bindestrich in der |
| 789 | // Konstante, Gedankenstrich in den Unterlagen ergab "120 s – … - Ersatzwert"). |
| 790 | expect(UMLAUFZEIT_ERSATZWERT_VERMERK).toBe('nicht bemessbar (übersättigt) – Ersatzwert'); |
| 791 | const roh = createStandardIntersectionProject('Uebersaettigt', DATUM_C); |
| 792 | const plan = buildSignalPlan({ |
| 793 | ...roh, |
| 794 | demands: roh.signalGroups |
| 795 | .filter((g) => g.mode === 'kfz') |
| 796 | .map((g) => ({ signalGroupId: g.id, volume: 1800, heavyVehicleShare: 0.1 })), |
| 797 | }); |
| 798 | expect(plan.cycleResult?.bounded).toBe('uebersaettigt'); |
| 799 | expect(umlaufzeitIstErsatzwert(plan)).toBe(true); |
| 800 | expect(umlaufzeitIstErsatzwert(buildSignalPlan(roh))).toBe(false); |
| 801 | }); |
| 802 | }); |