lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain engstellensignalisierung.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | engstellenFreigabezeit, |
| 4 | engstellenRaeumgeschwindigkeit, |
| 5 | engstellenUmlaufzeit, |
| 6 | engstellenZwischenzeit, |
| 7 | } from '@/domain/rilsa/engstellensignalisierung'; |
| 8 | import { computeEngstellenIntergreen, computeIntergreen } from '@/domain/rilsa/intergreen'; |
| 9 | import { RILSA_ENGSTELLE } from '@/domain/rilsa/constants'; |
| 10 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 11 | import { validateProject } from '@/domain/validation'; |
| 12 | import { |
| 13 | createConflict, |
| 14 | createEmptyProject, |
| 15 | createPhase, |
| 16 | createSignalGroup, |
| 17 | } from '@/domain/model/factory'; |
| 18 | import type { Project } from '@/domain/model/project'; |
| 19 | |
| 20 | /** |
| 21 | * Das eigene Rechenverfahren der Engstellensignalisierung |
| 22 | * (RiLSA 2015, Abschnitt 5.2), eingebaut mit der Fassung 5.27.0. |
| 23 | * |
| 24 | * WAS DIESE WACHE HAELT, ist nicht nur ein Satz Zahlen, sondern die |
| 25 | * Entscheidung dahinter: An einer einstreifigen Verkehrsfuehrung gilt ein |
| 26 | * ANDERES Verfahren als am Knotenpunkt, und zwar fuer den |
| 27 | * Kraftfahrzeugverkehr. Bis 5.26.0 rechnete das Programm dort die |
| 28 | * Knotenpunktformel. Jeder Fall nennt deshalb beide Zahlen - die von heute und |
| 29 | * die von damals -, sonst laesst sich nicht erkennen, was sich geaendert hat. |
| 30 | */ |
| 31 | |
| 32 | const T = new Date('2026-01-01T00:00:00Z'); |
| 33 | const OHNE_EINFAHRWEG = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 } as const; |
| 34 | |
| 35 | const raeumt = (clearingDistance: number, vZul: number) => |
| 36 | ({ mode: 'kfz', movement: 'geradeaus', clearingDistance, vehicleClass: 'pkw', vZul }) as const; |
| 37 | |
| 38 | describe('Raeumgeschwindigkeit der Engstelle (Abschnitt 5.2.2)', () => { |
| 39 | it.each([ |
| 40 | [60, 50], |
| 41 | [70, 50], |
| 42 | [50, 40], |
| 43 | [40, 30], |
| 44 | ])('setzt bei zulaessig %s km/h eine Raeumgeschwindigkeit von %s km/h an', (vZul, erwartet) => { |
| 45 | const ergebnis = engstellenRaeumgeschwindigkeit(vZul); |
| 46 | expect(ergebnis.vrKmh).toBe(erwartet); |
| 47 | expect(ergebnis.grund).toBe('vzul'); |
| 48 | }); |
| 49 | |
| 50 | it('nimmt unterhalb der Staffel die kleinste Stufe', () => { |
| 51 | // Die Staffel endet bei 40 km/h; darunter nennt die RiLSA keinen Wert. |
| 52 | expect(engstellenRaeumgeschwindigkeit(35)).toEqual({ vrKmh: 30, grund: 'unter-staffel' }); |
| 53 | expect(engstellenRaeumgeschwindigkeit(30)).toEqual({ vrKmh: 30, grund: 'unter-staffel' }); |
| 54 | }); |
| 55 | |
| 56 | it('raeumt nie schneller, als gefahren werden darf', () => { |
| 57 | /* |
| 58 | * Der Deckel aus 5.22.0, hier noch einmal: Bei Tempo 20 laege die |
| 59 | * kleinste Stufe der Staffel (30 km/h) ueber der zulaessigen |
| 60 | * Hoechstgeschwindigkeit. Ein Ansatz DIESES Programms - die RiLSA nennt |
| 61 | * fuer diesen Fall nichts - und die sichere Richtung. |
| 62 | */ |
| 63 | expect(engstellenRaeumgeschwindigkeit(20)).toEqual({ vrKmh: 20, grund: 'vmax-deckel' }); |
| 64 | }); |
| 65 | |
| 66 | it('setzt bei schlechter Fahrbahn 30 km/h an, unabhaengig von der Vzul', () => { |
| 67 | expect(engstellenRaeumgeschwindigkeit(60, { schlechteFahrbahn: true })).toEqual({ |
| 68 | vrKmh: 30, |
| 69 | grund: 'fahrbahn', |
| 70 | }); |
| 71 | }); |
| 72 | |
| 73 | it('macht den Radverkehr mit 18 km/h massgebend, wo er es ist', () => { |
| 74 | expect(engstellenRaeumgeschwindigkeit(60, { radverkehrMassgebend: true })).toEqual({ |
| 75 | vrKmh: 18, |
| 76 | grund: 'radverkehr', |
| 77 | }); |
| 78 | }); |
| 79 | |
| 80 | it('nimmt bei zwei Sonderfaellen den langsameren', () => { |
| 81 | // Eine Engstelle wird nicht dadurch schneller, dass zwei Gruende fuer |
| 82 | // Langsamkeit zusammentreffen. |
| 83 | expect( |
| 84 | engstellenRaeumgeschwindigkeit(60, { schlechteFahrbahn: true, radverkehrMassgebend: true }) |
| 85 | .vrKmh, |
| 86 | ).toBe(18); |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | describe('Zwischenzeit der Engstelle (Abschnitt 5.2.2)', () => { |
| 91 | it('rechnet tz = tue + sr/Vr * 3,6 mit fester Ueberfahrzeit von 4 s', () => { |
| 92 | const ergebnis = engstellenZwischenzeit(400, 50); |
| 93 | expect(ergebnis.ueberfahrzeit).toBe(RILSA_ENGSTELLE.ueberfahrzeit); |
| 94 | expect(ergebnis.ueberfahrzeit).toBe(4); |
| 95 | expect(ergebnis.raeumzeit).toBe(36); |
| 96 | expect(ergebnis.roh).toBe(40); |
| 97 | expect(ergebnis.zwischenzeit).toBe(40); |
| 98 | }); |
| 99 | |
| 100 | it('nimmt den Raeumweg OHNE Fahrzeuglaenge', () => { |
| 101 | /* |
| 102 | * Der Unterschied, der am leichtesten uebersehen wird: Am Knotenpunkt ist |
| 103 | * sr = s0 + lFZ, an der Engstelle ist sr der Abstand der Haltlinien. Die |
| 104 | * Gegenprobe rechnet dieselbe Geometrie nach beiden Verfahren. |
| 105 | */ |
| 106 | const engstelle = computeEngstellenIntergreen(raeumt(400, 50), OHNE_EINFAHRWEG); |
| 107 | const knotenpunkt = computeIntergreen(raeumt(400, 50), OHNE_EINFAHRWEG); |
| 108 | expect(engstelle.clearingPath, 'ohne Fahrzeuglaenge').toBe(400); |
| 109 | expect(knotenpunkt.clearingPath, 'mit 6 m Pkw').toBe(406); |
| 110 | expect(engstelle.vehicleLength).toBe(0); |
| 111 | expect(knotenpunkt.vehicleLength).toBe(6); |
| 112 | }); |
| 113 | |
| 114 | it('zieht keine Einfahrzeit ab und sagt es', () => { |
| 115 | // "Die Zwischenzeit tz wird ohne Beruecksichtigung einer Einfahrzeit |
| 116 | // ermittelt." Ein erfasster Einfahrweg geht damit in keine Zahl ein. |
| 117 | const mitWeg = computeEngstellenIntergreen(raeumt(400, 50), { |
| 118 | mode: 'kfz', |
| 119 | movement: 'geradeaus', |
| 120 | enteringDistance: 20, |
| 121 | }); |
| 122 | expect(mitWeg.enteringTime).toBe(0); |
| 123 | expect(mitWeg.intergreen).toBe(40); |
| 124 | const meldung = mitWeg.notes.find((n) => n.code === 'engstelle-einfahrweg-ohne-wirkung'); |
| 125 | expect(meldung?.severity).toBe('hinweis'); |
| 126 | expect(meldung?.message).toContain('5.2.2'); |
| 127 | }); |
| 128 | |
| 129 | it('kennzeichnet das Verfahren im Ergebnis', () => { |
| 130 | expect(computeEngstellenIntergreen(raeumt(400, 50), OHNE_EINFAHRWEG).verfahren).toBe( |
| 131 | 'engstelle', |
| 132 | ); |
| 133 | expect(computeIntergreen(raeumt(400, 50), OHNE_EINFAHRWEG).verfahren).toBe('knotenpunkt'); |
| 134 | }); |
| 135 | |
| 136 | it('faellt bei 40 km/h laenger aus als die Knotenpunktrechnung, bei 50 km/h kuerzer', () => { |
| 137 | /* |
| 138 | * DIE RICHTUNG HAENGT AN DER ZULAESSIGEN HOECHSTGESCHWINDIGKEIT, und das |
| 139 | * ist der Grund, warum diese Umstellung nicht pauschal "sicherer" oder |
| 140 | * "unsicherer" ist: |
| 141 | * |
| 142 | * 50 km/h: Vr 40 km/h = 11,11 m/s gegen 10,0 m/s - schneller, und der |
| 143 | * Raeumweg ist 6 m kuerzer. Oberhalb von 40 m Engstelle faellt |
| 144 | * die Zwischenzeit deshalb KUERZER aus. |
| 145 | * 40 km/h: Vr 30 km/h = 8,33 m/s gegen 10,0 m/s - langsamer. Die |
| 146 | * Zwischenzeit faellt LAENGER aus. |
| 147 | */ |
| 148 | expect(computeEngstellenIntergreen(raeumt(200, 50), OHNE_EINFAHRWEG).intergreen).toBe(22); |
| 149 | expect(computeIntergreen(raeumt(200, 50), OHNE_EINFAHRWEG).intergreen).toBe(24); |
| 150 | |
| 151 | expect(computeEngstellenIntergreen(raeumt(200, 40), OHNE_EINFAHRWEG).intergreen).toBe(28); |
| 152 | expect(computeIntergreen(raeumt(200, 40), OHNE_EINFAHRWEG).intergreen).toBe(24); |
| 153 | }); |
| 154 | |
| 155 | it('trifft bei 40 m und 50 km/h dieselbe Sekunde wie die Knotenpunktrechnung', () => { |
| 156 | // Ein nachgerechneter angeordneter Plan. Beide Wege ergeben 7,6 s -> 8 s. |
| 157 | expect(computeEngstellenIntergreen(raeumt(40, 50), OHNE_EINFAHRWEG).intergreen).toBe(8); |
| 158 | expect(computeIntergreen(raeumt(40, 50), OHNE_EINFAHRWEG).intergreen).toBe(8); |
| 159 | }); |
| 160 | }); |
| 161 | |
| 162 | describe('Die Handeingabe wirkt an der Engstelle nur nach unten', () => { |
| 163 | it('nimmt eine langsamere Raeumgeschwindigkeit an der Beziehung an', () => { |
| 164 | const langsam = computeEngstellenIntergreen( |
| 165 | { ...raeumt(400, 50), clearingSpeedOverride: 8 }, |
| 166 | OHNE_EINFAHRWEG, |
| 167 | ); |
| 168 | expect(langsam.clearingSpeed).toBe(8); |
| 169 | expect(langsam.intergreen).toBe(54); |
| 170 | }); |
| 171 | |
| 172 | it('verwirft eine schnellere und meldet das', () => { |
| 173 | const schnell = computeEngstellenIntergreen( |
| 174 | { ...raeumt(400, 50), clearingSpeedOverride: 13 }, |
| 175 | OHNE_EINFAHRWEG, |
| 176 | ); |
| 177 | expect(schnell.intergreen).toBe(40); |
| 178 | const meldung = schnell.notes.find( |
| 179 | (n) => n.code === 'engstelle-raeumgeschwindigkeit-ueber-regelwert', |
| 180 | ); |
| 181 | expect(meldung?.severity).toBe('warnung'); |
| 182 | }); |
| 183 | }); |
| 184 | |
| 185 | describe('Umlaufzeit und Freigabezeit der Engstelle (Abschnitt 5.2.3)', () => { |
| 186 | it('rechnet tU = (1,3 * TZ + 4) / (1 - Summe der Auslastungsgrade)', () => { |
| 187 | // TZ = 2 x 20 s, q1/qS1 + q2/qS2 = 0,4: (1,3 * 40 + 4) / 0,6 = 93,33 s. |
| 188 | expect(engstellenUmlaufzeit(40, 0.4)).toBeCloseTo(93.333, 3); |
| 189 | // Andere Beiwerte als Webster (1,5 und 5) und eine andere Bezugsgroesse: |
| 190 | // TZ ist die Summe der ZWISCHENzeiten, nicht die Verlustzeit je Umlauf. |
| 191 | expect(RILSA_ENGSTELLE.umlaufzeitFaktor).toBe(1.3); |
| 192 | expect(RILSA_ENGSTELLE.umlaufzeitZuschlag).toBe(4); |
| 193 | }); |
| 194 | |
| 195 | it('liefert unendlich, wo die Summe der Auslastungsgrade 1 erreicht', () => { |
| 196 | // "Dabei muss (q1/qS1 + q2/qS2) < 1 sein." |
| 197 | expect(engstellenUmlaufzeit(40, 1)).toBe(Number.POSITIVE_INFINITY); |
| 198 | expect(engstellenUmlaufzeit(40, 1.2)).toBe(Number.POSITIVE_INFINITY); |
| 199 | }); |
| 200 | |
| 201 | it('verteilt die verbleibende Zeit im Verhaeltnis der Auslastungsgrade', () => { |
| 202 | // tU 100 s, TZ 40 s, Auslastungsgrade 0,3 und 0,1: 45 s und 15 s. |
| 203 | expect(engstellenFreigabezeit(0.3, 0.4, 100, 40)).toBeCloseTo(45, 6); |
| 204 | expect(engstellenFreigabezeit(0.1, 0.4, 100, 40)).toBeCloseTo(15, 6); |
| 205 | }); |
| 206 | |
| 207 | it('nennt 1500 Kfz/h als ueberschlaegliche Saettigungsverkehrsstaerke', () => { |
| 208 | // Am Knotenpunkt rechnet dieses Programm mit 2000 Kfz/h nach HBS; fuer die |
| 209 | // Engstelle nennt die RiLSA diesen kleineren Anhaltswert. |
| 210 | expect(RILSA_ENGSTELLE.saettigungUeberschlaeglich).toBe(1500); |
| 211 | expect(RILSA_ENGSTELLE.maxUmlaufzeit).toBe(300); |
| 212 | }); |
| 213 | }); |
| 214 | |
| 215 | describe('Der Plan waehlt das Verfahren nach der Anlagenart', () => { |
| 216 | function baustelle(anlagenart: 'einstreifig' | 'knotenpunkt'): Project { |
| 217 | const basis = createEmptyProject('Baustelle', T, anlagenart); |
| 218 | const a = createSignalGroup({ name: 'K1', mode: 'kfz', vZul: 50, index: 0 }); |
| 219 | const b = createSignalGroup({ name: 'K2', mode: 'kfz', vZul: 50, index: 1 }); |
| 220 | const p1 = createPhase('Seite A', [a.id]); |
| 221 | const p2 = createPhase('Seite B', [b.id]); |
| 222 | return { |
| 223 | ...basis, |
| 224 | signalGroups: [a, b], |
| 225 | conflicts: [ |
| 226 | { ...createConflict(a.id, b.id), clearingDistance: 200, enteringDistance: 0 }, |
| 227 | { ...createConflict(b.id, a.id), clearingDistance: 200, enteringDistance: 0 }, |
| 228 | ], |
| 229 | phases: [p1, p2], |
| 230 | program: { ...basis.program, phaseOrder: [p1.id, p2.id] }, |
| 231 | }; |
| 232 | } |
| 233 | |
| 234 | it('rechnet die einstreifige Verkehrsfuehrung nach Abschnitt 5.2', () => { |
| 235 | const plan = buildSignalPlan(baustelle('einstreifig')); |
| 236 | for (const uebergang of plan.transitions) expect(uebergang.duration).toBe(22); |
| 237 | for (const zwischenzeit of plan.intergreens.values()) { |
| 238 | expect(zwischenzeit.calculation?.verfahren).toBe('engstelle'); |
| 239 | } |
| 240 | }); |
| 241 | |
| 242 | it('laesst den Knotenpunkt bei Abschnitt 2', () => { |
| 243 | // Dieselbe Geometrie, andere Anlagenart: 3 + 206/10 = 23,6 -> 24 s. |
| 244 | const plan = buildSignalPlan(baustelle('knotenpunkt')); |
| 245 | for (const uebergang of plan.transitions) expect(uebergang.duration).toBe(24); |
| 246 | for (const zwischenzeit of plan.intergreens.values()) { |
| 247 | expect(zwischenzeit.calculation?.verfahren).toBe('knotenpunkt'); |
| 248 | } |
| 249 | }); |
| 250 | |
| 251 | it('nennt der Pruefbericht das Verfahren und die beiden ungeprueften Annahmen', () => { |
| 252 | /* |
| 253 | * Das Ergebnis haengt an zwei Aussagen ueber die Oertlichkeit, die dieses |
| 254 | * Programm nicht pruefen kann. Beide senken die Raeumgeschwindigkeit; wer |
| 255 | * sie uebersieht, schaltet zu kurz. Der Bericht nennt sie mit den Zahlen |
| 256 | * DIESER Planung. |
| 257 | */ |
| 258 | const bericht = validateProject(baustelle('einstreifig'), undefined, T); |
| 259 | const befund = bericht.findings.find((f) => f.rule === 'einstreifig.verfahren-abschnitt-5-2'); |
| 260 | expect(befund?.severity).toBe('hinweis'); |
| 261 | expect(befund?.message).toContain('40 km/h'); |
| 262 | // Schlechte Fahrbahn: 30 km/h -> 4 + 200/30 * 3,6 = 28 s. |
| 263 | expect(befund?.message).toContain('30 km/h'); |
| 264 | expect(befund?.message).toContain('28 s'); |
| 265 | // Radverkehr massgebend: 18 km/h -> 4 + 200/18 * 3,6 = 44 s. |
| 266 | expect(befund?.message).toContain('18 km/h'); |
| 267 | expect(befund?.message).toContain('44 s'); |
| 268 | expect(befund?.reference).toContain('5.2.2'); |
| 269 | }); |
| 270 | |
| 271 | it('meldet das Verfahren am Knotenpunkt nicht', () => { |
| 272 | const bericht = validateProject(baustelle('knotenpunkt'), undefined, T); |
| 273 | expect(bericht.findings.some((f) => f.rule === 'einstreifig.verfahren-abschnitt-5-2')).toBe( |
| 274 | false, |
| 275 | ); |
| 276 | }); |
| 277 | |
| 278 | it('haelt die Untergrenze aus Abschnitt 2.5.2 von der Engstelle fern', () => { |
| 279 | /* |
| 280 | * Die Untergrenze tue + tr >= tG + 1 s gilt den Faellen 1 und 2 des |
| 281 | * Abschnitts 2.5.2 - also der Knotenpunktrechnung. Das Engstellenverfahren |
| 282 | * hat eine eigene, laengere Ueberfahrzeit und keine solche Bedingung; sie |
| 283 | * dorthin zu uebertragen hiesse, das Regelwerk zu ergaenzen. |
| 284 | */ |
| 285 | const kurz = computeEngstellenIntergreen(raeumt(1, 70), OHNE_EINFAHRWEG); |
| 286 | expect(kurz.ueberfahrRaeumzeitUntergrenze).toBeNull(); |
| 287 | // 4 + 1/50 * 3,6 = 4,07 -> 5 s. Mit der Untergrenze waeren es 6 s. |
| 288 | expect(kurz.intergreen).toBe(5); |
| 289 | }); |
| 290 | }); |