lsa-planer

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

/ tests domain anlagenartSchranken.test.ts

11,8 KB Rohdatei
tests/domain/anlagenartSchranken.test.ts — 275 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { anlagenartGrenzen, buildSignalPlan } from '@/domain/plan/signalPlan';
3 import { laengsteRotzeit, wartezeitBefunde } from '@/domain/validation/rules';
4 import { validateProject } from '@/domain/validation/engine';
5 import {
6 createConflict,
7 createEmptyProject,
8 createPhase,
9 createSignalGroup,
10 createStandardIntersectionProject,
11 } from '@/domain/model/factory';
12 import { RILSA_DEFAULTS } from '@/domain/rilsa/constants';
13 import type { Anlagenart, Project } from '@/domain/model/project';
14
15 const DATUM = new Date('2026-01-01T00:00:00Z');
16
17 /**
18 * Eine einstreifige Verkehrsfuehrung: zwei Richtungen, ein Fahrstreifen, die
19 * Engstelle ist der Raeumweg beider Richtungen.
20 */
21 function engstelle(
22 laenge: number,
23 anlagenart: Anlagenart = 'einstreifig',
24 festeUmlaufzeit: number | null = null,
25 ): Project {
26 const basis = createEmptyProject('Arbeitsstelle', DATUM, anlagenart);
27 const nord = createSignalGroup({ name: 'A1', mode: 'kfz', index: 0 });
28 const sued = createSignalGroup({ name: 'A2', mode: 'kfz', index: 1 });
29 const phase1 = createPhase('Richtung Nord', [nord.id]);
30 const phase2 = createPhase('Richtung Sued', [sued.id]);
31 const konflikte = [
32 { ...createConflict(nord.id, sued.id), clearingDistance: laenge, enteringDistance: 0 },
33 { ...createConflict(sued.id, nord.id), clearingDistance: laenge, enteringDistance: 0 },
34 ];
35
36 return {
37 ...basis,
38 meta: {
39 ...basis.meta,
40 projectNumber: 'B-2026-001',
41 client: 'Strassenmeisterei',
42 planner: 'O. Willerding',
43 location: 'B 1, km 12,4',
44 },
45 signalGroups: [nord, sued],
46 conflicts: konflikte,
47 phases: [phase1, phase2],
48 program: {
49 ...basis.program,
50 phaseOrder: [phase1.id, phase2.id],
51 ...(festeUmlaufzeit === null
52 ? {}
53 : { method: 'manuell' as const, manualCycleTime: festeUmlaufzeit }),
54 },
55 };
56 }
57
58 /** Fussgaengerschutzanlage: eine Fahrbahn, eine Furt, feste Umlaufzeit. */
59 function schutzanlage(umlaufzeit: number, gruenFurt: number): Project {
60 const basis = createEmptyProject('Furt', DATUM, 'fussgaengerschutzanlage');
61 const kfz = createSignalGroup({ name: 'K1', mode: 'kfz', index: 0 });
62 const fuss = createSignalGroup({ name: 'F1', mode: 'fuss', index: 1 });
63 const phase1 = createPhase('Fahrverkehr', [kfz.id]);
64 const phase2 = createPhase('Furt', [fuss.id]);
65 const konflikte = [
66 { ...createConflict(kfz.id, fuss.id), clearingDistance: 25, enteringDistance: 2 },
67 { ...createConflict(fuss.id, kfz.id), clearingDistance: 12, enteringDistance: 5 },
68 ];
69
70 return {
71 ...basis,
72 signalGroups: [kfz, fuss],
73 conflicts: konflikte,
74 phases: [phase1, { ...phase2, manualGreen: gruenFurt }],
75 program: {
76 ...basis.program,
77 phaseOrder: [phase1.id, phase2.id],
78 method: 'manuell',
79 manualCycleTime: umlaufzeit,
80 },
81 };
82 }
83
84 describe('Umlaufzeitschranken nach Anlagenart', () => {
85 it('laesst eine 400 m lange Engstelle ueber 120 s Umlauf zu', () => {
86 const plan = buildSignalPlan(engstelle(400));
87 /*
88 * Seit 5.27.0 nach RiLSA 2015, Abschnitt 5.2.2 (Engstellensignalisierung):
89 * 4 s Ueberfahrzeit + 400 m / 40 km/h * 3,6 = 40 s je Richtung. Bis 5.26.0
90 * rechnete das Programm hier die Knotenpunktformel - 400 m + 6 m
91 * Fahrzeuglaenge bei 10 m/s zuzueglich 3 s Gelbzeit, also 44 s. Damit
92 * liegt der Umlauf so oder so ueber 120 s, und darum geht es hier.
93 */
94 const uebergang = plan.transitions[0]?.duration ?? 0;
95 expect(uebergang).toBe(40);
96 expect(plan.cycleTime).toBeGreaterThan(120);
97 expect(plan.grenzen.cycleTime.max).toBe(300);
98 });
99
100 it('meldet den langen Umlauf der Arbeitsstelle nicht als Fehler', () => {
101 const bericht = validateProject(engstelle(400), undefined, DATUM);
102 expect(bericht.findings.some((f) => f.rule === 'signalplan.umlaufzeit-zu-lang')).toBe(false);
103 const fehler = bericht.findings.filter((f) => f.severity === 'fehler');
104 expect(fehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toBe('');
105 });
106
107 it('meldet den rechnerischen Bedarf am Knotenpunkt weiterhin als zu lang', () => {
108 // 400 m Engstelle an einem Knotenpunkt: Der Bedarf uebersteigt die 120 s
109 // der RiLSA. Dieselbe Geometrie als Arbeitsstelle bleibt unbeanstandet.
110 const knoten = validateProject(engstelle(400, 'knotenpunkt'), undefined, DATUM);
111 expect(knoten.findings.some((f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert')).toBe(
112 true,
113 );
114 const arbeitsstelle = validateProject(engstelle(400), undefined, DATUM);
115 expect(
116 arbeitsstelle.findings.some((f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert'),
117 ).toBe(false);
118 });
119
120 it('nennt die Anlagenart in der Meldung zur festen Umlaufzeit', () => {
121 const bericht = validateProject(engstelle(400, 'knotenpunkt', 200), undefined, DATUM);
122 const meldung = bericht.findings.find((f) => f.rule === 'signalplan.umlaufzeit-zu-lang');
123 expect(meldung?.message).toContain('Knotenpunkt');
124 expect(meldung?.message).toContain('120');
125 // Dieselben 200 s sind fuer eine einstreifige Verkehrsfuehrung zulaessig.
126 const arbeitsstelle = validateProject(engstelle(400, 'einstreifig', 200), undefined, DATUM);
127 expect(arbeitsstelle.findings.some((f) => f.rule === 'signalplan.umlaufzeit-zu-lang')).toBe(
128 false,
129 );
130 });
131
132 it('gibt der Arbeitsstelle einen brauchbaren Vorschlag statt "Zufahrt ausbauen"', () => {
133 // 1200 m Engstelle: 124 s Zwischenzeit je Richtung, damit sprengt selbst
134 // die Arbeitsstelle ihre Schranke von 300 s.
135 const bericht = validateProject(engstelle(1200), undefined, DATUM);
136 const meldung = bericht.findings.find(
137 (f) => f.rule === 'signalplan.umlaufzeit-ueber-hoechstwert',
138 );
139 // RSA-konforme Alternativen (Befund B7): keine "Verkehrsposten" -
140 // Warnposten duerfen keine Verkehrsregelung vornehmen.
141 expect(meldung?.suggestion).toContain('Umleitung mindestens einer Fahrtrichtung');
142 expect(meldung?.suggestion).toContain('RSA 21');
143 expect(meldung?.suggestion).not.toContain('Verkehrsposten');
144 expect(meldung?.suggestion).not.toContain('Begleitfahrzeug');
145 expect(meldung?.suggestion).not.toContain('Zufahrt');
146 });
147
148 it('nimmt die Schranke der Anlagenart, solange der Anwender nichts eintraegt', () => {
149 const grenzen = anlagenartGrenzen(engstelle(400), RILSA_DEFAULTS);
150 expect(grenzen.cycleTime.max).toBe(300);
151 expect(grenzen.cycleTime.min).toBe(30);
152 });
153
154 it('laesst eine ausdrueckliche Vorgabe des Anwenders einengen', () => {
155 const basis = engstelle(400);
156 const projekt: Project = {
157 ...basis,
158 settings: { ...basis.settings, rilsa: { cycleTimeMax: 100, cycleTimeMin: 60 } },
159 };
160 const plan = buildSignalPlan(projekt);
161 expect(plan.grenzen.cycleTime.max).toBe(100);
162 expect(plan.grenzen.cycleTime.min).toBe(60);
163 });
164 });
165
166 describe('Wartezeit vor Rot', () => {
167 it('warnt bei einer sehr langen Engstelle unter Nennung von Gruppe und Zahl', () => {
168 const plan = buildSignalPlan(engstelle(800));
169 const befunde = wartezeitBefunde(plan);
170 expect(befunde.length).toBeGreaterThan(0);
171 const erster = befunde[0]!;
172 expect(erster.severity).toBe('warnung');
173 expect(erster.message).toContain('A1');
174 expect(erster.message).toMatch(/\d/);
175 expect(erster.suggestion).toContain('Handschaltung');
176 expect(erster.suggestion).not.toContain('Verkehrsposten');
177 });
178
179 it('schweigt bei einer Engstelle, die die Schwelle einhaelt', () => {
180 expect(wartezeitBefunde(buildSignalPlan(engstelle(400)))).toHaveLength(0);
181 });
182
183 it('nennt an der Fussgaengerschutzanlage den Grund - Fussgaenger gehen bei Rot', () => {
184 const plan = buildSignalPlan(schutzanlage(90, 20));
185 const befund = wartezeitBefunde(plan).find((f) => f.message.includes('F1'));
186 expect(befund).toBeDefined();
187 expect(befund?.message).toContain('geht bei Rot');
188 expect(befund?.message).toContain('Schutzwirkung');
189 });
190
191 it('stuft die Fussgaengerschutzanlage ab 90 s als Fehler ein', () => {
192 const plan = buildSignalPlan(schutzanlage(120, 5));
193 const befund = wartezeitBefunde(plan).find((f) => f.message.includes('F1'));
194 expect(befund?.severity).toBe('fehler');
195 expect(befund?.rule).toBe('signalplan.wartezeit-unzumutbar');
196 });
197
198 it('misst bei zwei Freigabefenstern die groesste Luecke, nicht die Summe', () => {
199 // Zwei Fenster zu je 10 s in einem Umlauf von 100 s, gegenueberliegend:
200 // Der Fahrer wartet 40 s, nicht 80 s.
201 const gruen = [
202 { start: 0, duration: 10 },
203 { start: 50, duration: 10 },
204 ];
205 expect(laengsteRotzeit(gruen, 100)).toBe(40);
206 // Ein einziges Fenster: Umlaufzeit abzueglich eigener Freigabezeit.
207 expect(laengsteRotzeit([{ start: 0, duration: 10 }], 100)).toBe(90);
208 // Ueber den Umlaufbeginn hinweg.
209 expect(laengsteRotzeit([{ start: 95, duration: 10 }], 100)).toBe(90);
210 });
211 });
212
213 describe('Regeln, die fuer eine Anlagenart schweigen', () => {
214 it('fordert an der Arbeitsstelle keine Verkehrsstaerken', () => {
215 const bericht = validateProject(engstelle(400), undefined, DATUM);
216 expect(bericht.findings.some((f) => f.rule.startsWith('leistungsfaehigkeit.'))).toBe(false);
217 expect(bericht.findings.some((f) => f.rule === 'signalplan.saettigungsgrade-fehlen')).toBe(
218 false,
219 );
220 });
221
222 it('bewertet an der Fussgaengerschutzanlage die Furt, nicht den Fahrverkehr', () => {
223 /*
224 * Befund B4: Vor Fassung 5.4.0 hiess dieser Test "bewertet
225 * eine Fussgaengerschutzanlage nicht nach HBS" und verlangte, dass gar
226 * keine Leistungsfaehigkeitsmeldung erscheint. Das war nur zur Haelfte
227 * richtig: Der Fahrverkehr hat auf freier Strecke keinen Knotenpunkt und
228 * keine konkurrierende Zufahrt - Auslastungsgrad, Kfz-Stufe und die
229 * Forderung nach Verkehrsstaerken waeren dort sinnlos. Die Furt aber wird
230 * nach HBS 2015 sehr wohl bewertet, nach der MAXIMALEN Wartezeit (laengste
231 * Sperrzeit im Umlauf) - und an der Schutzanlage ist genau das das Mass,
232 * an dem die Anlage gemessen wird.
233 */
234
235 // 90 s Umlauf, 20 s Furtfreigabe: Sperrzeit 70 s -> Stufe D. Keine Meldung
236 // fuer die Furt, und keine Kfz-Meldung (keine Verkehrsstaerken gefordert,
237 // kein Auslastungsgrad).
238 const ruhig = validateProject(schutzanlage(90, 20), undefined, DATUM);
239 expect(ruhig.findings.filter((f) => f.category === 'leistungsfaehigkeit')).toHaveLength(0);
240
241 // 120 s Umlauf, 5 s Furtfreigabe: Sperrzeit ueber 85 s -> Stufe F nach der
242 // maximalen Wartezeit. Die Meldung gilt der Furt F1 und nennt das
243 // Kriterium; der Fahrverkehr K1 bleibt ohne Leistungsfaehigkeitsbefund.
244 const lang = validateProject(schutzanlage(120, 5), undefined, DATUM);
245 const leistung = lang.findings.filter((f) => f.category === 'leistungsfaehigkeit');
246 expect(leistung.map((f) => f.rule)).toEqual(['leistungsfaehigkeit.qualitaetsstufe']);
247 const furt = leistung[0]!;
248 expect(furt.target).toMatchObject({ kind: 'signalgruppe', label: 'F1' });
249 expect(furt.title).toContain('Qualitätsstufe F');
250 expect(furt.message).toContain('maximale Wartezeit');
251 expect(furt.message).toContain('HBS 2015');
252 expect(furt.message).not.toContain('K1');
253 expect(lang.findings.some((f) => f.rule === 'leistungsfaehigkeit.keine-verkehrsstaerken')).toBe(
254 false,
255 );
256 });
257
258 it('laesst den Knotenpunkt unveraendert', () => {
259 const basis = createStandardIntersectionProject('Prueffall', DATUM);
260 const projekt: Project = {
261 ...basis,
262 meta: {
263 ...basis.meta,
264 projectNumber: 'VP-2026-001',
265 client: 'Stadt Musterhausen',
266 planner: 'O. Willerding',
267 location: 'Hauptstrasse / Bahnhofstrasse',
268 },
269 };
270 const bericht = validateProject(projekt, undefined, DATUM);
271 const fehler = bericht.findings.filter((f) => f.severity === 'fehler');
272 expect(fehler.map((f) => `${f.rule}: ${f.message}`).join('\n')).toBe('');
273 expect(bericht.findings.some((f) => f.rule.startsWith('signalplan.wartezeit'))).toBe(false);
274 });
275 });