lsa-planer

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

/ tests domain kennwertquellen.test.ts

18,6 KB Rohdatei
tests/domain/kennwertquellen.test.ts — 408 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { RILSA_DEFAULTS } from '@/domain/rilsa/constants';
3 import { computeCycleTime } from '@/domain/rilsa/cycle';
4 import { distributeGreenTimes } from '@/domain/rilsa/greenSplit';
5 import { QUELLEN, type QuellenSchluessel } from '@/domain/rilsa/quellen';
6 import { settingFields } from '@/domain/rilsa/settings';
7 import type { RilsaOverrides } from '@/domain/rilsa/settings';
8 import {
9 createPhase,
10 createSignalGroup,
11 createStandardIntersectionProject,
12 } from '@/domain/model/factory';
13 import { buildSignalPlan } from '@/domain/plan/signalPlan';
14 import type { SignalPlan } from '@/domain/plan/signalPlan';
15 import type { Project } from '@/domain/model/project';
16
17 /**
18 * Nachbarfaelle zu Befund 19, behoben in Fassung 5.12.0.
19 *
20 * Befund 19 hat Ziel- und Hoechst-Auslastungsgrad aus der Sammelquelle
21 * 'praxis' herausgeloest, weil deren gedruckter Hinweis - "Frei wählbar; wirkt
22 * sich nicht auf die Einhaltung des Regelwerks aus" - fuer sie nicht zutrifft.
23 * Der Satz steht als "Zu beachten" im Fundstellenverzeichnis der
24 * Planunterlage. Drei Vorgabenfelder blieben an der Sammelquelle haengen; fuer
25 * alle drei ist er nachgemessen falsch:
26 *
27 * A `minGreenOepnv` - geht ueber die Mindestfreigabezeit der Phase in die
28 * Mindestumlaufzeit ein; die Umlaufzeit ist an
29 * Regelwerksgrenzen gebunden.
30 * B `cycleTimeStep` - `ceilToStep` laeuft vor der Kappung auf den
31 * Hoechstwert; die Rasterung entscheidet damit allein
32 * darueber, ob die Ueberschreitung gemeldet wird.
33 * C `maxGreen` - kappt eine Phase, verschiebt deren Zeit auf die
34 * uebrigen und veraendert darueber Kapazitaet,
35 * Auslastungsgrad und Qualitaetsstufe nach HBS 2015.
36 *
37 * C war als "kein Treffer" gemeldet, mit der Begruendung, die
38 * Mindestfreigabezeit gehe immer vor und der Wert gehe nicht in die
39 * Umlaufzeit ein. Beides trifft zu und ist unten mitgemessen; der Schluss
40 * daraus traegt nicht.
41 *
42 * Der letzte Fall der Datei bewacht, dass der neue Hinweis zu C nicht seinerseits
43 * eine Meldung zusagt, die es fuer das Vorgabenfeld nicht gibt.
44 */
45
46 const SATZ_DER_WIRKUNGSLOSIGKEIT = 'wirkt sich nicht auf die Einhaltung des Regelwerks aus';
47
48 /** Die drei Felder, um die es geht - mit der Fundstelle, die sie tragen sollen. */
49 const FELDER = [
50 { key: 'minGreenOepnv', schluessel: 'praxis-mindestfreigabezeit-oepnv' },
51 { key: 'maxGreen', schluessel: 'praxis-hoechstfreigabezeit' },
52 { key: 'cycleTimeStep', schluessel: 'praxis-umlaufzeit-rasterung' },
53 ] as const satisfies readonly { key: keyof RilsaOverrides; schluessel: QuellenSchluessel }[];
54
55 function feld(key: keyof RilsaOverrides) {
56 const gefunden = settingFields().find((f) => f.key === key);
57 expect(gefunden, `Vorgabenfeld "${key}"`).toBeDefined();
58 return gefunden!;
59 }
60
61 describe('Die drei letzten Felder der Sammelquelle "praxis"', () => {
62 it('haengt keines der drei Felder mehr an der Sammelquelle', () => {
63 for (const { key } of FELDER) {
64 expect(feld(key).quellenSchluessel, key).not.toBe('praxis');
65 }
66 });
67
68 it('gibt jedem der drei eine eigene Fundstelle - der Hinweissatz trifft nicht auf alle drei zu', () => {
69 for (const { key, schluessel } of FELDER) {
70 expect(feld(key).quellenSchluessel, key).toBe(schluessel);
71 }
72 const schluessel = new Set(FELDER.map((f) => feld(f.key).quellenSchluessel));
73 expect(schluessel.size, 'drei verschiedene Fundstellen').toBe(FELDER.length);
74 });
75
76 it('druckt zu keinem der drei Felder den Satz von der Wirkungslosigkeit', () => {
77 for (const { key } of FELDER) {
78 expect(feld(key).quelle.hinweis ?? '', key).not.toContain(SATZ_DER_WIRKUNGSLOSIGKEIT);
79 expect(feld(key).quelle.verwendung, key).not.toContain(SATZ_DER_WIRKUNGSLOSIGKEIT);
80 }
81 });
82
83 it('nennt in jeder Fundstelle, worauf der Wert wirkt', () => {
84 // A und B wirken auf die Umlaufzeit, C auf die Bewertung nach HBS 2015.
85 expect(feld('minGreenOepnv').quelle.verwendung).toContain('Mindestumlaufzeit');
86 expect(feld('cycleTimeStep').quelle.verwendung).toContain('Umlaufzeit');
87 expect(feld('maxGreen').quelle.verwendung).toContain('HBS 2015');
88 for (const { key } of FELDER) {
89 expect(feld(key).quelle.hinweis ?? '', `${key}: Beleglage`).toContain('Kein Regelwerkswert');
90 }
91 });
92
93 it('fuehrt alle drei als selbstgesetzte Schwelle, nie als gesichert', () => {
94 for (const { key } of FELDER) {
95 // Grundsatz: selbstgesetzte Schwellen tragen nie "gesichert".
96 expect(feld(key).quelle.pruefstand, key).not.toBe('gesichert');
97 expect(feld(key).quelle.regelwerk, key).not.toContain('RiLSA');
98 expect(feld(key).quelle.regelwerk, key).not.toContain('HBS');
99 }
100 });
101
102 it('bleibt in Kurzform bei der Planungspraxis - die Spalte des Verzeichnisses aendert sich nicht', () => {
103 for (const { key } of FELDER) {
104 expect(feld(key).quelle.kurz, key).toBe('Planungspraxis');
105 expect(feld(key).quelle.regelwerk, key).toBe('Planungspraxis');
106 }
107 });
108
109 /*
110 * Die Sammelquelle bleibt stehen - sie benennt den Massstab, an dem die drei
111 * Trennungen gemessen wurden. Sie traegt jetzt aber kein Vorgabenfeld mehr
112 * und erscheint damit in keinem Verzeichnis; gedruckt wird ihr Satz
113 * nirgends.
114 */
115 it('laesst den Satz an der Sammelquelle stehen, die aber kein Vorgabenfeld mehr traegt', () => {
116 expect(QUELLEN.praxis.hinweis ?? '').toContain(SATZ_DER_WIRKUNGSLOSIGKEIT);
117 expect(settingFields().filter((f) => f.quellenSchluessel === 'praxis')).toHaveLength(0);
118 });
119 });
120
121 describe('Die Wirkung, die der Satz der Sammelquelle bestritt', () => {
122 /*
123 * A - die Mindestfreigabezeit des OePNV. Zweimal gemessen: einmal am
124 * fertigen Plan, damit die Kette Vorgabe -> Phase -> Mindestumlaufzeit
125 * belegt ist, und einmal an der Umlaufzeitrechnung selbst, damit die
126 * Regelwerksgrenze erreicht wird.
127 */
128 const DATUM = new Date('2026-01-01T00:00:00Z');
129
130 function projektMitOepnv(minGreenOepnv?: number): Project {
131 const basis = createStandardIntersectionProject('Prüffall', DATUM);
132 const bahn = createSignalGroup({
133 name: 'B1',
134 mode: 'oepnv',
135 armId: basis.intersection.arms[0]?.id ?? null,
136 index: 6,
137 });
138 return {
139 ...basis,
140 signalGroups: [...basis.signalGroups, bahn],
141 phases: [...basis.phases, createPhase('Phase 3 - ÖPNV', [bahn.id])],
142 settings: {
143 ...basis.settings,
144 rilsa:
145 minGreenOepnv === undefined
146 ? basis.settings.rilsa
147 : { ...basis.settings.rilsa, minGreenOepnv },
148 },
149 };
150 }
151
152 it('verlaengert die Mindestfreigabezeit des OePNV die Umlaufzeit des fertigen Plans', () => {
153 expect(RILSA_DEFAULTS.minGreen.oepnv, 'Regelwert').toBe(3);
154 const mitRegelwert = buildSignalPlan(projektMitOepnv());
155 const mitVorgabe = buildSignalPlan(projektMitOepnv(60));
156 expect(mitRegelwert.cycleTime, 'Umlaufzeit mit 3 s').toBe(65);
157 expect(mitVorgabe.cycleTime, 'Umlaufzeit mit 60 s').toBe(80);
158 expect(mitRegelwert.notes.map((n) => n.code)).not.toContain('mindestumlauf-massgebend');
159 expect(mitVorgabe.notes.map((n) => n.code)).toContain('mindestumlauf-massgebend');
160 });
161
162 it('treibt sie die Mindestumlaufzeit bis ueber den Hoechstwert der RiLSA', () => {
163 // Phasenfolge mit 5 + 25 s Mindestfreigabe und 35 s Uebergangszeiten;
164 // dazwischen die Phase des Sondersignals.
165 const eingang = (minGreenOepnv: number) => ({
166 lostTime: 20,
167 criticalFlowRatios: [0.2, 0.2],
168 minimumCycle: 5 + 25 + minGreenOepnv + 35,
169 step: RILSA_DEFAULTS.cycleTime.step,
170 });
171 const klein = computeCycleTime('webster', eingang(3));
172 const gross = computeCycleTime('webster', eingang(60));
173 expect(klein.cycleTime, 'Umlaufzeit bei 3 s').toBe(70);
174 expect(klein.notes.map((n) => n.code)).not.toContain('umlaufzeit-ueber-hoechstwert');
175 expect(gross.bounded, 'Zustand bei 60 s').toBe('maximum');
176 expect(gross.cycleTime).toBe(RILSA_DEFAULTS.cycleTime.max);
177 expect(gross.notes.map((n) => n.code)).toContain('umlaufzeit-ueber-hoechstwert');
178 });
179
180 /*
181 * B - die Rasterung. Derselbe rechnerische Wert, dieselbe ausgegebene
182 * Umlaufzeit - und seit Fassung 5.13.0 auch dieselbe Beurteilung.
183 *
184 * GEAENDERT (Fassung 5.13.0). Dieser Fall hielt bis dahin fest, dass die
185 * Rasterung allein ueber die Beanstandung entscheidet: bei 7 und 9 s der
186 * Fehler 'umlaufzeit-ueber-hoechstwert', bei den uebrigen acht Rasterungen
187 * keiner. Das war eine Messung des Zustands, nicht seine Billigung - der
188 * Kopfkommentar der Fundstelle sagte ausdruecklich, die Reihenfolge in
189 * cycle.ts sei damit nicht entschieden.
190 *
191 * Sie ist es jetzt: Beurteilt wird der erforderliche Umlauf VOR der
192 * Rasterung. Der Fall misst weiterhin, was die Fundstelle behauptet, nur
193 * eben den entschiedenen Zustand - die Wirkung der Rasterung auf die
194 * Umlaufzeit selbst steht im Fall darunter. Kein Abschwaechen: Die
195 * Messreihe ueber alle zehn Rasterungen bleibt, und die Gegenprobe mit
196 * einem wirklich zu langen Umlauf kommt hinzu. Die ausfuehrliche Wache
197 * dazu steht in `umlaufzeitRasterung.test.ts`.
198 */
199 it('entscheidet die Rasterung nicht mehr, ob die Ueberschreitung des Hoechstwerts gemeldet wird', () => {
200 // (1,5 * L + 5) / (1 - Y) = 119,48 s bei Y = 0,5.
201 const Y = 0.5;
202 const eingang = (step: number, raw = 119.48) => ({
203 lostTime: (raw * (1 - Y) - 5) / 1.5,
204 criticalFlowRatios: [Y],
205 minimumCycle: 30,
206 step,
207 });
208 const teilend = computeCycleTime('webster', eingang(5));
209 const nichtTeilend = computeCycleTime('webster', eingang(7));
210
211 expect(teilend.raw, 'rechnerischer Wert').toBeCloseTo(119.48, 2);
212 expect(nichtTeilend.raw, 'derselbe rechnerische Wert').toBeCloseTo(119.48, 2);
213 // Dieselbe Umlaufzeit - und in beiden Faellen kein Fehler im Bericht.
214 expect(teilend.cycleTime).toBe(120);
215 expect(nichtTeilend.cycleTime).toBe(120);
216 expect(teilend.bounded).toBe('keine');
217 expect(nichtTeilend.bounded).toBe('keine');
218 const meldend = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter((step) =>
219 computeCycleTime('webster', eingang(step))
220 .notes.map((n) => n.code)
221 .includes('umlaufzeit-ueber-hoechstwert'),
222 );
223 expect(meldend, 'keine Rasterung des Feldbereichs meldet').toEqual([]);
224
225 // Gegenprobe: Ein erforderlicher Umlauf ueber dem Hoechstwert wird bei
226 // jeder Rasterung gemeldet - auch bei einer, die ihn teilt.
227 const zuLang = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter((step) =>
228 computeCycleTime('webster', eingang(step, 121))
229 .notes.map((n) => n.code)
230 .includes('umlaufzeit-ueber-hoechstwert'),
231 );
232 expect(zuLang).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
233 });
234
235 it('wirkt die Rasterung weiterhin auf die Umlaufzeit selbst', () => {
236 /*
237 * Der Grund, aus dem dieses Feld eine eigene Fundstelle hat, bleibt
238 * bestehen: Die Rasterung ist keine wirkungslose Anzeigegroesse. An der
239 * Standardvorlage (rechnerisch 88,07 s) ergibt sie bei 5 s eine
240 * Umlaufzeit von 90 s mit den Freigabezeiten 42/28 s, bei 1 s eine von
241 * 89 s mit 42/27 s.
242 */
243 const basis = createStandardIntersectionProject('Prüffall', DATUM);
244 const mitRaster = (cycleTimeStep: number): Project => ({
245 ...basis,
246 settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, cycleTimeStep } },
247 });
248 const fuenf = buildSignalPlan(mitRaster(5));
249 const eins = buildSignalPlan(mitRaster(1));
250 expect(fuenf.cycleResult?.raw, 'derselbe rechnerische Wert').toBeCloseTo(88.07, 2);
251 expect(eins.cycleResult?.raw, 'derselbe rechnerische Wert').toBeCloseTo(88.07, 2);
252 expect(fuenf.cycleTime).toBe(90);
253 expect(eins.cycleTime).toBe(89);
254 expect(fuenf.phases.map((p) => p.duration)).toEqual([42, 28]);
255 expect(eins.phases.map((p) => p.duration)).toEqual([42, 27]);
256 });
257
258 /*
259 * C - die Hoechstfreigabezeit. Zuerst die beiden Feststellungen, die der
260 * gemeldete Nachbarfall richtig getroffen hat, dann der Schluss, der nicht
261 * traegt.
262 */
263 it('geht die Mindestfreigabezeit der Hoechstfreigabezeit vor', () => {
264 const ergebnis = distributeGreenTimes(
265 90,
266 [
267 { id: 'furt', weight: 0.4, minGreen: 25, maxGreen: 10 },
268 { id: 'kfz', weight: 0.4, minGreen: 5, maxGreen: 120 },
269 ],
270 [10, 10],
271 );
272 expect(ergebnis.phases.find((p) => p.id === 'furt')?.green, 'Furtphase').toBe(25);
273 });
274
275 it('aendert die Hoechstfreigabezeit die Bewertung nach HBS 2015', () => {
276 const basis = createStandardIntersectionProject('Prüffall', DATUM);
277 const mit = (maxGreen: number): Project => ({
278 ...basis,
279 settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, maxGreen } },
280 });
281
282 expect(RILSA_DEFAULTS.maxGreen, 'Regelwert').toBe(120);
283 const regelwert = buildSignalPlan(basis);
284 const gekappt = buildSignalPlan(mit(10));
285 expect(regelwert.cycleTime, 'Umlaufzeit unveraendert').toBe(90);
286 expect(gekappt.cycleTime, 'Umlaufzeit unveraendert').toBe(90);
287 expect(regelwert.phases.map((p) => p.duration)).toEqual([42, 28]);
288 /*
289 * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0): Hier stand [60, 10]. Der
290 * Rest, den die gekappte Hoechstfreigabezeit uebrig laesst, ging bis dahin
291 * ganz an die Phase mit dem groessten Gewicht; jetzt wird er nach
292 * Gewichten auf die Phasen mit Spielraum verteilt. Die Aussage dieses
293 * Falls - die Hoechstfreigabezeit aendert die Freigabezeiten und damit die
294 * Bewertung - traegt unveraendert, nur die Aufteilung ist eine andere.
295 */
296 expect(gekappt.phases.map((p) => p.duration)).toEqual([40, 30]);
297
298 /*
299 * NACHGEZOGEN AM 10.09.2026 (Fassung 5.23.0). Hier stand die Ueberlastung:
300 * Mit gekappter Hoechstfreigabezeit fiel die zweite Phase auf 10 s, und
301 * `leistungsfaehigkeit.ueberlastet` stand als Fehler im Bericht. Seit der
302 * Rest nach Gewichten auf die Phasen mit Spielraum verteilt wird, bekommt
303 * sie 30 s statt 10 s, und diese Ueberlastung entsteht nicht mehr - das
304 * ist die Absicht der Aenderung und kein Verlust.
305 *
306 * PUNKT C BRAUCHT SIE AUCH NICHT. Behauptet ist, die Hoechstfreigabezeit
307 * veraendere Kapazitaet, Auslastungsgrad und Qualitaetsstufe nach HBS
308 * 2015; alle drei sind unmittelbar messbar und haengen an keiner Schwelle.
309 * Das ist die staerkere Wache: Ein Fehler im Bericht setzt voraus, dass
310 * die Wirkung gerade eine Grenze ueberschreitet, die Wirkung selbst nicht.
311 */
312 const kfz = (plan: SignalPlan, name: string) => {
313 const gruppe = plan.groups.find((g) => g.name === name);
314 expect(gruppe, `Signalgruppe ${name}`).toBeDefined();
315 return gruppe!;
316 };
317
318 expect(kfz(regelwert, 'K1').capacity?.capacity, 'Kapazität mit dem Regelwert').toBe(914);
319 expect(kfz(gekappt, 'K1').capacity?.capacity, 'Kapazität mit 10 s').toBe(872);
320 expect(
321 kfz(regelwert, 'K1').capacity?.degreeOfSaturation,
322 'Auslastungsgrad mit dem Regelwert',
323 ).toBeCloseTo(0.678, 3);
324 expect(kfz(gekappt, 'K1').capacity?.degreeOfSaturation, 'Auslastungsgrad mit 10 s').toBeCloseTo(
325 0.711,
326 3,
327 );
328 /*
329 * Und die Bewertung: die mittlere Wartezeit der ersten Fussgaengerfurt,
330 * aus der die Qualitaetsstufe folgt.
331 *
332 * NACHGEZOGEN MIT FASSUNG 5.43.0: Hier stand der Wechsel ihrer Stufe von B
333 * auf C. Die Vorlage erfasst seither F1 auch gegen K4, der die Furt beim
334 * Ausfahren quert, und rechnet den einfahrenden Fussgaenger mit te = 0 s.
335 * F1 wartet dadurch schon mit dem Regelwert 10,8 s und steht auf C; die
336 * gekappte Hoechstfreigabezeit verlaengert die Wartezeit weiter,
337 * ueberschreitet aber keine Stufengrenze mehr. Gemessen wird deshalb die
338 * Groesse, die an keiner Schwelle haengt.
339 */
340 expect(kfz(regelwert, 'F1').delay?.averageDelay, 'Wartezeit mit dem Regelwert').toBe(10.8);
341 expect(kfz(gekappt, 'F1').delay?.averageDelay, 'Wartezeit mit 10 s').toBe(11.8);
342 });
343
344 /*
345 * NACHTRAG (Fassung 5.12.0): Der gedruckte Hinweis dieser Fundstelle
346 * schloss zunaechst mit dem pauschalen Satz, eine Hoechstfreigabezeit unter
347 * der Mindestfreigabezeit sei "nicht schaltbar und wird als wirkungslos
348 * gemeldet". Gedruckt wird er im Fundstellenverzeichnis unter dem
349 * VORGABENFELD - und fuer das galt die zweite Haelfte nicht: phaseMaxGreen
350 * kehrte ohne Meldung zurueck, wenn keine Signalgruppe eine kleinere
351 * Schranke trug. Damit stand unter einem Kennwert wieder ein Satz, der ihn
352 * falsch beschreibt: genau der Fehlertyp, gegen den diese
353 * Datei steht.
354 *
355 * GEAENDERT (Fassung 5.13.0): Der Hinweis beschrieb damit einen Zustand, der
356 * als gemeldet, aber unentschieden festgehalten war. Entschieden ist er jetzt
357 * zugunsten der Meldung - eine wirkungslose Eingabe, von der niemand
358 * erfaehrt, bleibt sonst stehen. Der Fall misst weiterhin beide Lagen und
359 * bindet den Wortlaut daran; neu ist, dass die Vorgabe gemeldet WIRD. Die
360 * ausfuehrliche Wache dazu steht in
361 * `hoechstfreigabezeitUnterMindestfreigabezeit.test.ts`.
362 */
363 it('meldet auch eine Projektvorgabe unter der Mindestfreigabezeit als wirkungslos - einmal fuer die Anlage', () => {
364 const basis = createStandardIntersectionProject('Prüffall', DATUM);
365 // Beide Werte liegen im Feldbereich der Vorgabenverwaltung
366 // (Mindestfreigabezeiten bis 60 s, Hoechstfreigabezeit ab 10 s).
367 const plan = buildSignalPlan({
368 ...basis,
369 settings: {
370 ...basis.settings,
371 rilsa: { ...basis.settings.rilsa, minGreenKfz: 40, maxGreen: 10 },
372 },
373 });
374 expect(plan.cycleTime, 'Umlaufzeit').toBe(100);
375 expect(
376 plan.phases.map((p) => p.duration),
377 'Freigabezeiten',
378 ).toEqual([40, 40]);
379 expect(
380 plan.notes.map((n) => n.code),
381 'Meldungen des Plans',
382 ).toEqual([
383 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit',
384 'mindestumlauf-massgebend',
385 ]);
386 expect(
387 plan.notes.find((n) => n.code === 'hoechstfreigabezeit-vorgabe-unter-mindestfreigabezeit')
388 ?.signalGroupId,
389 'die Zahl steht an keiner Signalgruppe',
390 ).toBeUndefined();
391
392 // Dieselbe Schranke je Signalgruppe eingetragen: dort haengt die Meldung
393 // an der Gruppe.
394 const jeGruppe = buildSignalPlan({
395 ...basis,
396 signalGroups: basis.signalGroups.map((g, i) => (i === 0 ? { ...g, maxGreenOverride: 2 } : g)),
397 });
398 expect(jeGruppe.notes.map((n) => n.code)).toContain(
399 'hoechstfreigabezeit-unter-mindestfreigabezeit',
400 );
401
402 // Der gedruckte Hinweis der Fundstelle sagt genau diese Lage - und nicht mehr.
403 const hinweis = feld('maxGreen').quelle.hinweis ?? '';
404 expect(hinweis).toContain('eine je Signalgruppe eingetragene Höchstfreigabezeit');
405 expect(hinweis).toContain('einmal für die Anlage');
406 expect(hinweis, 'der alte, unentschiedene Zustand').not.toContain('ohne Beanstandung');
407 });
408 });