lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain rilsaAbgleich2015.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | ANLAGENART_GRENZEN, |
| 4 | OEPNV_UEBERGANGSZEIT_STAFFEL, |
| 5 | RILSA_DEFAULTS, |
| 6 | RILSA_FURT, |
| 7 | RILSA_KOORDINIERUNG, |
| 8 | RILSA_PHASENUEBERGANG, |
| 9 | UEBERFAHR_RAEUMZEIT_ZUSCHLAG, |
| 10 | } from '@/domain/rilsa/constants'; |
| 11 | import { yellowTime } from '@/domain/rilsa/signalTimes'; |
| 12 | import { computeIntergreen } from '@/domain/rilsa/intergreen'; |
| 13 | import { computeCoordination } from '@/domain/rilsa/coordination'; |
| 14 | import { QUELLEN } from '@/domain/rilsa/quellen'; |
| 15 | |
| 16 | /** |
| 17 | * Der Abgleich an der GEKAUFTEN RiLSA 2015 (10.09.2026, Fassung 5.27.0). |
| 18 | * |
| 19 | * Bis dahin hatte dieses Programm das Regelwerk nie gesehen. Es stuetzte sich |
| 20 | * auf das FGSV-Aenderungsblatt vom 29.07.2015 - acht Seiten, die nur die |
| 21 | * Aenderungen 2010 -> 2015 auflisten -, auf behoerdliche Leitfaeden und auf |
| 22 | * Umkehrschluesse aus Zwischenzeitentafeln. Das trug erstaunlich weit: Der |
| 23 | * Kern stimmt Zahl fuer Zahl. Was nicht trug, steht hier. |
| 24 | * |
| 25 | * DIESE DATEI BEWACHT DIE BERICHTIGUNGEN, nicht den Kern - der ist anderswo |
| 26 | * bewacht. Jeder Fall nennt, was bis 5.26.0 galt; sonst liest sich die Zeile |
| 27 | * wie eine beliebige Zusicherung, und beim naechsten Zweifel setzt jemand den |
| 28 | * alten Wert zurueck. |
| 29 | */ |
| 30 | |
| 31 | const OHNE_EINFAHRWEG = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 } as const; |
| 32 | |
| 33 | describe('Untergrenze der Ueberfahr- und Raeumzeit (Abschnitt 2.5.2)', () => { |
| 34 | /* |
| 35 | * "Fuer das Ueberfahren und Raeumen geradeaus fahrender Kraftfahrzeuge |
| 36 | * (Fall 1) oder abbiegender Kraftfahrzeuge (Fall 2) ist einzuhalten: |
| 37 | * tue + tr >= tG + 1". Bis 5.26.0 war diese Schranke NICHT eingebaut, und |
| 38 | * der Quelltext begruendete das ausfuehrlich - die Regel habe "als |
| 39 | * woertliche Quelle nur eine Webseite, die sich im selben Text |
| 40 | * widerspricht". |
| 41 | */ |
| 42 | const geradeaus = (clearingDistance: number, vZul: number) => |
| 43 | computeIntergreen( |
| 44 | { mode: 'kfz', movement: 'geradeaus', clearingDistance, vehicleClass: 'pkw', vZul }, |
| 45 | OHNE_EINFAHRWEG, |
| 46 | ); |
| 47 | |
| 48 | it('setzt den Zuschlag auf die eine Sekunde des Regelwerks', () => { |
| 49 | // Nicht auf die Rot-Gelb-Zeit: Die ist einstellbar (bis 2 s), die RiLSA |
| 50 | // schreibt die Zahl 1. |
| 51 | expect(UEBERFAHR_RAEUMZEIT_ZUSCHLAG).toBe(1); |
| 52 | }); |
| 53 | |
| 54 | it('hebt die Summe an, wo der Raeumweg kurz ist', () => { |
| 55 | // 50 km/h: tG = 3 s, Untergrenze 4 s. Raeumweg 0 m + 6 m Pkw = 6 m, |
| 56 | // tr = 0,6 s, tue = 3 s (Gelbzeitkopplung) -> 3,6 s. |
| 57 | const kurz = geradeaus(0, 50); |
| 58 | expect(kurz.crossingTime).toBe(3); |
| 59 | expect(kurz.clearingTime).toBe(0.6); |
| 60 | expect(kurz.ueberfahrRaeumzeitUntergrenze).toBe(4); |
| 61 | expect(kurz.intergreen).toBe(4); |
| 62 | }); |
| 63 | |
| 64 | it('laesst die Rechnung in Ruhe, sobald die Summe die Untergrenze erreicht', () => { |
| 65 | // 4 m Grundraeumweg + 6 m = 10 m, tr = 1,0 s, Summe genau 4,0 s. Die |
| 66 | // Toleranz gegen Gleitkommarauschen darf hier nicht anschlagen. |
| 67 | const genau = geradeaus(4, 50); |
| 68 | expect(genau.ueberfahrRaeumzeitUntergrenze).toBeNull(); |
| 69 | expect(genau.intergreen).toBe(4); |
| 70 | expect(genau.notes.some((n) => n.code === 'ueberfahr-raeumzeit-untergrenze')).toBe(false); |
| 71 | }); |
| 72 | |
| 73 | it('waechst mit der Gelbzeit, weil die Untergrenze an tG haengt', () => { |
| 74 | // Dieselbe Geometrie bei 70 km/h: tG = 5 s, Untergrenze 6 s. |
| 75 | const schnell = geradeaus(0, 70); |
| 76 | expect(schnell.ueberfahrRaeumzeitUntergrenze).toBe(6); |
| 77 | expect(schnell.intergreen).toBe(6); |
| 78 | }); |
| 79 | |
| 80 | it('zieht die Einfahrzeit auch von der angehobenen Summe ab', () => { |
| 81 | /* |
| 82 | * Die Untergrenze gilt der Ueberfahr- und Raeumzeit, nicht der |
| 83 | * Zwischenzeit: tz = (tue + tr) - te. Wer sie auf tz legte, verlaengerte |
| 84 | * jede Beziehung mit Einfahrweg unzulaessig. |
| 85 | */ |
| 86 | const mitEinfahrweg = computeIntergreen( |
| 87 | { mode: 'kfz', movement: 'geradeaus', clearingDistance: 0, vehicleClass: 'pkw', vZul: 50 }, |
| 88 | { mode: 'kfz', movement: 'geradeaus', enteringDistance: 11.1 }, |
| 89 | ); |
| 90 | expect(mitEinfahrweg.ueberfahrRaeumzeitUntergrenze).toBe(4); |
| 91 | // 4 s - 1 s Einfahrzeit = 3 s. |
| 92 | expect(mitEinfahrweg.enteringTime).toBe(1); |
| 93 | expect(mitEinfahrweg.raw).toBe(3); |
| 94 | expect(mitEinfahrweg.intergreen).toBe(3); |
| 95 | }); |
| 96 | }); |
| 97 | |
| 98 | describe('Uebergangszeit gesondert signalisierter OePNV-Fahrzeuge (Abschnitt 2.4)', () => { |
| 99 | /* |
| 100 | * Bis 5.26.0 lieferte yellowTime fuer OePNV-Gruppen 0 s mit dem Vermerk |
| 101 | * "Sondersignale (Balkensignale) kennen kein Gelb". Sie kennen kein GELB, |
| 102 | * aber ein Uebergangssignal nach Par. 51 BOStrab, und die RiLSA staffelt |
| 103 | * seine Dauer nach der betrieblich zugelassenen Hoechstgeschwindigkeit. |
| 104 | */ |
| 105 | it('staffelt sie 4/5/6/7/8 s nach der Hoechstgeschwindigkeit', () => { |
| 106 | expect(OEPNV_UEBERGANGSZEIT_STAFFEL.map((s) => [s.upToKmh, s.uebergang])).toEqual([ |
| 107 | [30, 4], |
| 108 | [40, 5], |
| 109 | [50, 6], |
| 110 | [60, 7], |
| 111 | [70, 8], |
| 112 | ]); |
| 113 | }); |
| 114 | |
| 115 | it.each([ |
| 116 | [20, 4], |
| 117 | [30, 4], |
| 118 | [31, 5], |
| 119 | [40, 5], |
| 120 | [50, 6], |
| 121 | [60, 7], |
| 122 | [70, 8], |
| 123 | ])('gibt bei %s km/h %s s', (vZul, erwartet) => { |
| 124 | expect(yellowTime('oepnv', vZul)).toBe(erwartet); |
| 125 | }); |
| 126 | |
| 127 | it('nimmt oberhalb der Staffel und ohne Vmax die oberste Stufe', () => { |
| 128 | // Dieselbe Regel wie bei der Ueberfahrzeit-Staffel: die laengere Zeit. |
| 129 | expect(yellowTime('oepnv', 80)).toBe(8); |
| 130 | expect(yellowTime('oepnv', 0)).toBe(8); |
| 131 | }); |
| 132 | |
| 133 | it('laesst das Rot-Gelb der Sondersignale bei null', () => { |
| 134 | // Das Uebergangssignal gibt es nur beim Wechsel von Frei auf Gesperrt. |
| 135 | expect(RILSA_DEFAULTS.minGreen.oepnv).toBeGreaterThan(0); |
| 136 | expect(yellowTime('fuss', 50)).toBe(0); |
| 137 | }); |
| 138 | }); |
| 139 | |
| 140 | describe('Gelbzeit des gesondert signalisierten Radverkehrs (Abschnitt 2.4)', () => { |
| 141 | it('betraegt 2 s und nicht mehr 3 s', () => { |
| 142 | // Die RiLSA nennt fuer gesondert signalisierten Radverkehr einheitlich |
| 143 | // 2 s Gelb und 1 s Rot/Gelb. Hier standen bis 5.26.0 drei Sekunden. |
| 144 | expect(RILSA_DEFAULTS.yellowRad).toBe(2); |
| 145 | expect(yellowTime('rad', 50)).toBe(2); |
| 146 | }); |
| 147 | |
| 148 | it('laesst die Ueberfahrzeit des Radverkehrs unberuehrt', () => { |
| 149 | // Fall 5 setzt tue = 1 s an, unabhaengig von der Gelbzeit. Wer beides |
| 150 | // koppelte, verkuerzte jede Radbeziehung um eine Sekunde. |
| 151 | expect(RILSA_DEFAULTS.crossingTime.radDefault).toBe(1); |
| 152 | const rad = computeIntergreen( |
| 153 | { mode: 'rad', movement: 'geradeaus', clearingDistance: 20, vehicleClass: 'rad', vZul: 50 }, |
| 154 | OHNE_EINFAHRWEG, |
| 155 | ); |
| 156 | expect(rad.crossingTime).toBe(1); |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | describe('Umlaufzeit und Auslastungsgrad (Abschnitt 2.6)', () => { |
| 161 | it('beginnt den Regelbereich am Knotenpunkt bei 30 s', () => { |
| 162 | // "Als Richtwerte fuer die Umlaufzeit gelten: minimal 30 s, maximal |
| 163 | // 90 (120) s." Bis 5.26.0 begann der Bereich bei 60 s - einer Zahl dieses |
| 164 | // Programms -, und jeder Umlauf darunter wurde beanstandet. |
| 165 | expect(RILSA_DEFAULTS.cycleTime.recommendedMin).toBe(30); |
| 166 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMin).toBe(30); |
| 167 | expect(ANLAGENART_GRENZEN.knotenpunkt.cycleTime.recommendedMax).toBe(90); |
| 168 | // Und die Zeile sagt es auch: gesichert, mit Fundstelle. |
| 169 | expect(ANLAGENART_GRENZEN.knotenpunkt.herkunft.regelbereich.pruefstand).toBe('gesichert'); |
| 170 | }); |
| 171 | |
| 172 | it('haelt beide Auslastungsgrade im Band 0,80 bis 0,90', () => { |
| 173 | const { targetDegreeOfSaturation, maxDegreeOfSaturation } = RILSA_DEFAULTS.capacity; |
| 174 | for (const wert of [targetDegreeOfSaturation, maxDegreeOfSaturation]) { |
| 175 | expect(wert).toBeGreaterThanOrEqual(0.8); |
| 176 | expect(wert).toBeLessThanOrEqual(0.9); |
| 177 | } |
| 178 | // Der Hoechstwert lag bis 5.26.0 bei 0,95 und damit ausserhalb. |
| 179 | expect(maxDegreeOfSaturation).toBe(0.9); |
| 180 | expect(targetDegreeOfSaturation).toBeLessThan(maxDegreeOfSaturation); |
| 181 | }); |
| 182 | }); |
| 183 | |
| 184 | describe('Koordinierung (Abschnitt 4.4.4)', () => { |
| 185 | const knoten = (name: string, distanceToPrevious: number) => ({ |
| 186 | id: name, |
| 187 | name, |
| 188 | distanceToPrevious, |
| 189 | greenStart: 0, |
| 190 | greenDuration: 30, |
| 191 | }); |
| 192 | |
| 193 | it('kennt die relative Regel und den Wirkbereich', () => { |
| 194 | expect(RILSA_KOORDINIERUNG.progressionsanteilVon).toBe(0.9); |
| 195 | expect(RILSA_KOORDINIERUNG.progressionsanteilBis).toBe(1); |
| 196 | expect(RILSA_KOORDINIERUNG.wirkbereichRegel).toBe(750); |
| 197 | expect(RILSA_KOORDINIERUNG.wirkbereichGuenstig).toBe(1000); |
| 198 | expect(RILSA_KOORDINIERUNG.auslastungsgradGrenze).toBe(0.85); |
| 199 | }); |
| 200 | |
| 201 | it('meldet eine Progressionsgeschwindigkeit ausserhalb von 90 bis 100 % der Vzul', () => { |
| 202 | // 50 km/h bei Vzul 70: unter 63 km/h und damit ausserhalb. Ohne die |
| 203 | // uebergebene Vzul gibt es die Aussage nicht - das Programm erfindet keine. |
| 204 | const mitVzul = computeCoordination( |
| 205 | [knoten('A', 0), knoten('B', 300)], |
| 206 | 90, |
| 207 | 50, |
| 208 | RILSA_DEFAULTS, |
| 209 | 70, |
| 210 | ); |
| 211 | expect( |
| 212 | mitVzul.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), |
| 213 | ).toBe(true); |
| 214 | |
| 215 | const ohneVzul = computeCoordination([knoten('A', 0), knoten('B', 300)], 90, 50); |
| 216 | expect( |
| 217 | ohneVzul.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), |
| 218 | ).toBe(false); |
| 219 | }); |
| 220 | |
| 221 | it('schweigt innerhalb des Bandes', () => { |
| 222 | // 45 km/h bei Vzul 50: genau 90 %, die untere Kante gehoert dazu. |
| 223 | const ergebnis = computeCoordination( |
| 224 | [knoten('A', 0), knoten('B', 300)], |
| 225 | 90, |
| 226 | 45, |
| 227 | RILSA_DEFAULTS, |
| 228 | 50, |
| 229 | ); |
| 230 | expect( |
| 231 | ergebnis.notes.some((n) => n.code === 'progressionsgeschwindigkeit-ausserhalb-rilsa'), |
| 232 | ).toBe(false); |
| 233 | }); |
| 234 | |
| 235 | it('stuft den Abstand zwischen den Knotenpunkten nach dem Wirkbereich ein', () => { |
| 236 | const knapp = computeCoordination([knoten('A', 0), knoten('B', 800)], 90, 50); |
| 237 | expect(knapp.notes.find((n) => n.code === 'abstand-ueber-regelwirkbereich')?.severity).toBe( |
| 238 | 'hinweis', |
| 239 | ); |
| 240 | expect(knapp.notes.some((n) => n.code === 'abstand-ueber-wirkbereich')).toBe(false); |
| 241 | |
| 242 | const weit = computeCoordination([knoten('A', 0), knoten('B', 1200)], 90, 50); |
| 243 | expect(weit.notes.find((n) => n.code === 'abstand-ueber-wirkbereich')?.severity).toBe( |
| 244 | 'warnung', |
| 245 | ); |
| 246 | |
| 247 | const nah = computeCoordination([knoten('A', 0), knoten('B', 400)], 90, 50); |
| 248 | expect(nah.notes.some((n) => n.code.startsWith('abstand-ueber'))).toBe(false); |
| 249 | }); |
| 250 | }); |
| 251 | |
| 252 | describe('Kennwerte ohne Rechenwirkung - benannt statt verschwiegen', () => { |
| 253 | /* |
| 254 | * Furtbreiten (Abschnitt 3.6) und die Zeiten der Phasenuebergaenge |
| 255 | * (Abschnitt 2.7) gehen in keine Zahl ein: Das Programm fuehrt keine |
| 256 | * Furtbreite und bildet die bedingt vertraegliche Fuehrung nicht ab. Sie |
| 257 | * stehen trotzdem als Konstanten da - dieselbe Begruendung wie bei |
| 258 | * HBS_STANDARDBEDINGUNGEN: Wer die Unterlage prueft, muss lesen koennen, |
| 259 | * was das Programm NICHT prueft. |
| 260 | */ |
| 261 | it('haelt die Furtbreiten der RiLSA fest', () => { |
| 262 | expect(RILSA_FURT.regelbreite).toBe(4); |
| 263 | expect(RILSA_FURT.mindestbreite).toBe(3); |
| 264 | expect(RILSA_FURT.zweiterSignalgeberAb).toBe(8); |
| 265 | }); |
| 266 | |
| 267 | it('haelt Mindestsperrzeit, Zeitvorsprung und verzoegerten Beginn fest', () => { |
| 268 | expect(RILSA_PHASENUEBERGANG.mindestsperrzeit).toBe(1); |
| 269 | expect(RILSA_PHASENUEBERGANG.zeitvorsprungVon).toBe(1); |
| 270 | expect(RILSA_PHASENUEBERGANG.zeitvorsprungBis).toBe(2); |
| 271 | expect(RILSA_PHASENUEBERGANG.verzoegerterBeginnVon).toBe(2); |
| 272 | expect(RILSA_PHASENUEBERGANG.verzoegerterBeginnBis).toBe(4); |
| 273 | }); |
| 274 | }); |
| 275 | |
| 276 | describe('Fundstellen - genannt, wo das Original vorliegt', () => { |
| 277 | it('haengt an jede RiLSA-Quelle eine am Original nachgeschlagene Nummer', () => { |
| 278 | const rilsa = Object.entries(QUELLEN).filter(([, q]) => q.regelwerk.includes('RiLSA 2015')); |
| 279 | expect(rilsa.length).toBeGreaterThanOrEqual(6); |
| 280 | for (const [schluessel, quelle] of rilsa) { |
| 281 | expect(quelle.fundstelle, schluessel).toMatch(/^Abschnitt\s+\d/); |
| 282 | } |
| 283 | }); |
| 284 | |
| 285 | it('laesst HBS, RSA und VwV-StVO ohne Nummer', () => { |
| 286 | // Diese Werke liegen dem Programm nicht vor. Eine geratene Nummer schickt |
| 287 | // den Pruefer an die falsche Stelle - dieselbe Lehre wie Befund D4. |
| 288 | for (const schluessel of ['hbs-kapazitaet', 'rsa-arbeitsstellen', 'vwv-stvo'] as const) { |
| 289 | expect(QUELLEN[schluessel].fundstelle, schluessel).toBeUndefined(); |
| 290 | } |
| 291 | }); |
| 292 | }); |