lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests domain rilsaAbgleich2015.test.ts

11,7 KB Rohdatei
tests/domain/rilsaAbgleich2015.test.ts — 292 Zeilen
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 });