lsa-planer

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

/ tests domain ersatzbelegeRichtungUndGroessenordnung.test.ts

19,0 KB Rohdatei
tests/domain/ersatzbelegeRichtungUndGroessenordnung.test.ts — 468 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import {
4 leiteAb,
5 uebernimmSignalgruppen,
6 uebernimmWege,
7 wegSchluessel,
8 } from '@/domain/geometrie/ableitung';
9 import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
10 import { REGELBREITE } from '@/domain/geometrie/vermessung';
11 import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory';
12 import { parseProject } from '@/domain/model/schema';
13 import type { Project } from '@/domain/model/project';
14 import { buildSignalPlan } from '@/domain/plan/signalPlan';
15 import { schwerverkehrsfaktor } from '@/domain/rilsa/capacity';
16 import { HBS_PKW_GLEICHWERTE, RILSA_DEFAULTS } from '@/domain/rilsa/constants';
17 import { computeIntergreen, engerRadiusWirktAn } from '@/domain/rilsa/intergreen';
18 import { QUELLEN } from '@/domain/rilsa/quellen';
19 import { resolveRilsaSettings } from '@/domain/rilsa/settings';
20 import type { ClearingStream, EnteringStream } from '@/domain/rilsa/types';
21 import { validateProject } from '@/domain/validation/engine';
22 import { schwerverkehrSatz } from '@/services/export/bewertung';
23
24 /**
25 * Richtung, Groessenordnung und Herleitung der Ersatzbelege - die Befunde B1
26 * bis B6 und F1 bis F7 der Fassung 5.5.0; die Kennungen in dieser Datei
27 * meinen diese Befunde.
28 *
29 * Alle Faelle hier schlagen gegen den Stand vom 15.08.2026 fehl. Der rote
30 * Faden ist derselbe wie in allen Tests zu den Ersatzbelegen: Keine Ausgabe
31 * darf eine Richtung, eine Groessenordnung oder eine Herleitung behaupten, die
32 * die Rechnung daneben nicht hergibt.
33 *
34 * B1 Der enge Innenradius VERKUERZTE die Zwischenzeit, sobald die
35 * Raeumgeschwindigkeit des Abbiegers unter 5 m/s abgesenkt war - die
36 * Feldschranke misst gegen den Regelwert 7,0 m/s, nicht gegen den
37 * wirksamen Wert.
38 * B2 Eine von Hand eingetragene Raeumgeschwindigkeit gewinnt gegen das
39 * Merkmal. Das stand nirgends, war nirgends geprueft, und drei Ausgaben
40 * behaupteten das Gegenteil.
41 * B3 Ein unlesbares `engerRadius` fiel ohne Meldung auf die unsichere Seite.
42 * B4 Die Uebernahme aus dem Lageplan waehlte den massgebenden Weg nach einer
43 * anderen Rechnung als der Plan.
44 * B5 Fest eingeschriebene Zahlen im Fehlertext ("3 s geradeaus, 2 s
45 * abbiegend"), obwohl beide Werte Vorgabenfelder sind.
46 * F1 "bis zu 2,3 %" war keine Obergrenze - der Zuwachs waechst monoton bis
47 * 5,3 %.
48 * F2 Die groesste unsichere Annahme (keine Aufteilung des Schwerverkehrs)
49 * stand ohne Fehlerrichtung in der gedruckten Unterlage.
50 * F6 Ein Referat war als Zitat ausgegeben.
51 */
52
53 const DATUM = new Date('2026-01-01T00:00:00Z');
54 const UEBERNAHME = new Date('2026-08-15T09:30:00Z');
55
56 function raeumenderAbbieger(rest: Partial<ClearingStream> = {}): ClearingStream {
57 return {
58 mode: 'kfz',
59 movement: 'rechts',
60 clearingDistance: 25,
61 vehicleClass: 'pkw',
62 vZul: 50,
63 ...rest,
64 };
65 }
66
67 const EINFAHREND: EnteringStream = { mode: 'kfz', movement: 'geradeaus', enteringDistance: 0 };
68
69 // --- B1: Das Merkmal darf nie verkuerzen -----------------------------------
70
71 describe('B1 - enger Innenradius gegen abgesenkte Raeumgeschwindigkeit des Abbiegers', () => {
72 it('kappt die Vorgabe auf die wirksame Raeumgeschwindigkeit des Abbiegers und meldet es', () => {
73 // Beide Werte lagen einzeln in ihren Schranken: 4 m/s (Feld 3 bis 10) und
74 // 5 m/s (Feld 2 bis 7). Zusammen ergaben sie einen "engen Innenradius",
75 // der SCHNELLER raeumt als der Regelfall.
76 const { defaults, notes, deviations } = resolveRilsaSettings({
77 clearingSpeedKfzTurning: 4,
78 clearingSpeedKfzTurningEngerRadius: 5,
79 });
80
81 expect(defaults.clearingSpeed.kfzTurningEngerRadius).toBe(4);
82 const meldung = notes.find((n) => n.code.endsWith('clearingSpeedKfzTurningEngerRadius'));
83 expect(meldung?.severity).toBe('fehler');
84 expect(meldung?.message).toContain('verkürzte');
85
86 // Die Abweichungsliste fuehrt den ANGESETZTEN Wert - sonst druckte der
87 // Ausdruck 5 m/s neben einer Rechnung mit 4 m/s (Befund C21).
88 const abweichung = deviations.find((d) => d.key === 'clearingSpeedKfzTurningEngerRadius');
89 expect(abweichung?.appliedValue).toBe(4);
90 });
91
92 it('laesst die Zwischenzeit mit dem Merkmal nie kuerzer ausfallen als ohne', () => {
93 const { defaults } = resolveRilsaSettings({
94 clearingSpeedKfzTurning: 4,
95 clearingSpeedKfzTurningEngerRadius: 5,
96 });
97 const ohne = computeIntergreen(raeumenderAbbieger(), EINFAHREND, defaults).intergreen;
98 const mit = computeIntergreen(
99 raeumenderAbbieger({ engerRadius: true }),
100 EINFAHREND,
101 defaults,
102 ).intergreen;
103
104 // Gegen den Altstand: ohne 11 s, mit 10 s - das Merkmal verkuerzte um 1 s.
105 expect(ohne).toBe(11);
106 expect(mit).toBeGreaterThanOrEqual(ohne);
107 });
108
109 it('meldet den Widerspruch auch, wenn ein Kennwertsatz ihn von Hand mitbringt', () => {
110 // Zweiter Riegel: Wer computeIntergreen mit einem selbst gebauten
111 // Kennwertsatz aufruft, laeuft nicht an resolveRilsaSettings vorbei.
112 const kennwerte = {
113 ...RILSA_DEFAULTS,
114 clearingSpeed: {
115 ...RILSA_DEFAULTS.clearingSpeed,
116 kfzTurning: 4,
117 kfzTurningEngerRadius: 5,
118 },
119 };
120 const ergebnis = computeIntergreen(
121 raeumenderAbbieger({ engerRadius: true }),
122 EINFAHREND,
123 kennwerte,
124 );
125 expect(ergebnis.clearingSpeed).toBe(4);
126 expect(ergebnis.notes.map((n) => n.code)).toContain('enger-radius-schneller-als-regelfall');
127 });
128
129 it('nennt in der Schrankenbegruendung keine feste Zahl mehr', () => {
130 // Die Schranke gilt gegen den eingetragenen Nachbarwert, nicht gegen 7 m/s.
131 const { notes } = resolveRilsaSettings({ clearingSpeedKfzTurningEngerRadius: 9 });
132 const meldung = notes.find((n) => n.code.endsWith('clearingSpeedKfzTurningEngerRadius'));
133 expect(meldung?.message).toContain('höchstens 7 m/s');
134 expect(meldung?.message).not.toContain('ohne dieses Merkmal (7 m/s)');
135 });
136 });
137
138 // --- B2: Wer gewinnt - Merkmal oder Handeingabe? ---------------------------
139
140 describe('B2 - Vorrang der eingetragenen Raeumgeschwindigkeit vor dem Merkmal', () => {
141 it('beantwortet die Frage an einer Stelle des Fachkerns', () => {
142 const basis = { mode: 'kfz', movement: 'rechts', engerRadius: true } as const;
143 expect(engerRadiusWirktAn(basis)).toBe(true);
144 expect(engerRadiusWirktAn({ ...basis, clearingSpeedOverride: 9 })).toBe(false);
145 // Ein ungueltiger Eintrag faellt auf den Regelwert zurueck - dann wirkt
146 // das Merkmal weiter, genau wie in resolveClearingSpeed.
147 expect(engerRadiusWirktAn({ ...basis, clearingSpeedOverride: 0 })).toBe(true);
148 expect(engerRadiusWirktAn({ ...basis, movement: 'geradeaus' })).toBe(false);
149 expect(engerRadiusWirktAn({ ...basis, engerRadius: false })).toBe(false);
150 });
151
152 it('vermerkt den Vorrang im Rechenweg der Beziehung', () => {
153 const ergebnis = computeIntergreen(
154 raeumenderAbbieger({ engerRadius: true, clearingSpeedOverride: 9 }),
155 EINFAHREND,
156 );
157 expect(ergebnis.clearingSpeed).toBe(9);
158 const notiz = ergebnis.notes.find((n) => n.code === 'enger-radius-durch-vorgabe-ersetzt');
159 expect(notiz?.severity).toBe('warnung');
160 expect(notiz?.message).toContain('Vorrang');
161 expect(notiz?.message).toContain('KÜRZER');
162 });
163
164 it('meldet im Pruefbericht den Vorrang statt einer Verlaengerung', () => {
165 const roh = createStandardIntersectionProject('Vorrang', DATUM);
166 const k1 = roh.signalGroups.find((g) => g.name === 'K1')!;
167 const f2 = roh.signalGroups.find((g) => g.name === 'F2')!;
168 const project: Project = {
169 ...roh,
170 signalGroups: roh.signalGroups.map((g) =>
171 g.id === k1.id ? { ...g, movement: 'rechts' } : g,
172 ),
173 conflicts: roh.conflicts.map((c) =>
174 c.fromId === k1.id && c.toId === f2.id
175 ? { ...c, engerRadius: true, clearingSpeedOverride: 9 }
176 : c,
177 ),
178 };
179 const bericht = validateProject(project, buildSignalPlan(project), DATUM);
180
181 // Der Hinweis "die Zwischenzeit faellt laenger aus" darf hier NICHT stehen.
182 expect(bericht.findings.map((f) => f.rule)).not.toContain('zwischenzeiten.enger-radius');
183 const warnung = bericht.findings.find(
184 (f) => f.rule === 'zwischenzeiten.enger-radius-ohne-wirkung',
185 );
186 expect(warnung?.severity).toBe('warnung');
187 expect(warnung?.message).toContain('Vorrang');
188 expect(warnung?.suggestion).toContain('Entfernen Sie eines von beiden');
189 });
190 });
191
192 // --- B3: Unlesbare Merkmale --------------------------------------------------
193
194 describe('B3 - unlesbare Wahrheitswerte fallen nicht mehr stumm auf die unsichere Seite', () => {
195 function mitRohemMerkmal(feld: string, wert: unknown): ReturnType<typeof parseProject> {
196 const basis = createStandardIntersectionProject('Unlesbar', DATUM);
197 const roh = JSON.parse(JSON.stringify(basis)) as Record<string, unknown>;
198 const konflikte = roh['conflicts'] as Record<string, unknown>[];
199 konflikte[0]![feld] = wert;
200 return parseProject(roh);
201 }
202
203 it('meldet ein unlesbares engerRadius', () => {
204 const { project, issues } = mitRohemMerkmal('engerRadius', 'ja');
205 expect(project.conflicts[0]?.engerRadius).toBe(false);
206 const meldung = issues.find((i) => i.path.endsWith('.engerRadius'));
207 expect(meldung?.severity).toBe('warnung');
208 expect(meldung?.message).toContain('kürzer');
209 });
210
211 it('meldet ein unlesbares haltVorKnoten', () => {
212 const { issues } = mitRohemMerkmal('haltVorKnoten', 'vielleicht');
213 expect(issues.find((i) => i.path.endsWith('.haltVorKnoten'))?.severity).toBe('warnung');
214 });
215
216 it('schweigt weiterhin zum FEHLENDEN Feld', () => {
217 // Eine Datei bis Schema 11 fuehrt das Feld nicht. Das ist keine
218 // Unlesbarkeit, sondern die Aussage "nicht gesetzt".
219 const basis = createStandardIntersectionProject('Ohne Feld', DATUM);
220 const { project, issues } = parseProject(JSON.parse(JSON.stringify(basis)));
221 expect(project.conflicts[0]?.engerRadius).toBeUndefined();
222 expect(issues.filter((i) => i.path.includes('engerRadius'))).toHaveLength(0);
223 });
224 });
225
226 // --- B4: Uebernahme aus dem Lageplan ---------------------------------------
227
228 describe('B4 - die Uebernahme rechnet mit den Ansaetzen des Projekts', () => {
229 /** Zwei kreuzende Linien; die raeumende biegt ab. */
230 function lageplanMitAbbieger(): Lageplan {
231 const linie = (
232 id: string,
233 name: string,
234 movement: Planlinie['movement'],
235 punkte: readonly { x: number; y: number }[],
236 ): Planlinie => ({
237 id,
238 name,
239 mode: 'kfz',
240 movement,
241 breiteMeter: REGELBREITE.kfz,
242 punkte,
243 haltlinieId: null,
244 startT: 0.5,
245 signalGroupId: null,
246 });
247
248 return {
249 arbeitsbereiche: [],
250 signalgeber: [],
251 haltlinien: [],
252 bild: {
253 datenUrl: 'data:image/png;base64,AAAA',
254 breite: 400,
255 hoehe: 400,
256 herkunft: 'Test',
257 geladenAm: DATUM.toISOString(),
258 },
259 kalibrierung: {
260 von: { x: 0, y: 0 },
261 bis: { x: 100, y: 0 },
262 laengeMeter: 50,
263 herkunft: 'gemessen',
264 },
265 linien: [
266 linie('l-w', 'K1', 'rechts', [
267 { x: 0, y: 100 },
268 { x: 200, y: 100 },
269 ]),
270 linie('l-s', 'K2', 'geradeaus', [
271 { x: 100, y: 0 },
272 { x: 100, y: 200 },
273 ]),
274 ],
275 };
276 }
277
278 /** Die uebernommene Zwischenzeit steht in der Bemerkung der Beziehung. */
279 function uebernommeneZwischenzeit(project: Project): number {
280 const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(
281 project,
282 lageplanMitAbbieger(),
283 ['l-w', 'l-s'],
284 );
285 const ableitung = leiteAb(mitGruppen, lageplan);
286 const weg = ableitung.wege.find((w) => w.vonLinieId === 'l-w');
287 expect(weg).toBeDefined();
288 const ergebnis = uebernimmWege(
289 mitGruppen,
290 lageplan,
291 ableitung,
292 [wegSchluessel(weg!)],
293 UEBERNAHME,
294 ).project;
295 const konflikt = ergebnis.conflicts.find((c) => c.note.includes('maßgebend'));
296 expect(konflikt, 'Keine Beziehung mit Uebernahmevermerk').toBeDefined();
297 const treffer = /\((\d+) s\)/.exec(konflikt!.note);
298 expect(treffer, `Keine Zwischenzeit im Vermerk: ${konflikt!.note}`).not.toBeNull();
299 return Number(treffer![1]);
300 }
301
302 it('rechnet den Vergleich mit dem gewaehlten Ueberfahrzeit-Ansatz', () => {
303 const basis = createEmptyProject('Uebernahme', DATUM);
304 const mitGelbzeit = uebernommeneZwischenzeit(basis);
305 const mitFest = uebernommeneZwischenzeit({
306 ...basis,
307 settings: { ...basis.settings, ueberfahrzeitAnsatz: 'fest' },
308 });
309
310 // Abbiegend: Gelbzeit 3 s gegen feste Ueberfahrzeit 2 s. Gegen den
311 // Altstand sind beide Zahlen gleich - die Uebernahme kannte den Ansatz
312 // nicht und rechnete immer mit der Gelbzeit.
313 expect(mitFest).toBe(mitGelbzeit - 1);
314 });
315
316 it('rechnet den Vergleich mit dem Merkmal der vorhandenen Beziehung', () => {
317 const basis = createEmptyProject('Uebernahme Radius', DATUM);
318 const ohne = uebernommeneZwischenzeit(basis);
319
320 // Dieselbe Uebernahme ein zweites Mal, nachdem die Beziehung das Merkmal
321 // traegt: Der Abbieger raeumt jetzt mit 5,0 statt 7,0 m/s.
322 const { project: mitGruppen, lageplan } = uebernimmSignalgruppen(basis, lageplanMitAbbieger(), [
323 'l-w',
324 'l-s',
325 ]);
326 const ableitung = leiteAb(mitGruppen, lageplan);
327 const weg = ableitung.wege.find((w) => w.vonLinieId === 'l-w')!;
328 const einmal = uebernimmWege(
329 mitGruppen,
330 lageplan,
331 ableitung,
332 [wegSchluessel(weg)],
333 UEBERNAHME,
334 ).project;
335 const mitMerkmal: Project = {
336 ...einmal,
337 conflicts: einmal.conflicts.map((c) =>
338 c.note.includes('maßgebend') ? { ...c, engerRadius: true } : c,
339 ),
340 };
341 const nachher = uebernimmWege(
342 mitMerkmal,
343 lageplan,
344 leiteAb(mitMerkmal, lageplan),
345 [wegSchluessel(weg)],
346 UEBERNAHME,
347 ).project;
348 const treffer = /\((\d+) s\)/.exec(
349 nachher.conflicts.find((c) => c.note.includes('maßgebend'))!.note,
350 );
351 expect(Number(treffer![1])).toBeGreaterThan(ohne);
352 });
353 });
354
355 // --- B5 und F3: keine Zahl aus dem Satz ------------------------------------
356
357 describe('B5/F3 - der Fehlertext zur festen Ueberfahrzeit nennt die eingetragenen Werte', () => {
358 it('widerspricht sich nicht mehr, wenn die festen Werte geaendert sind', () => {
359 const { defaults } = resolveRilsaSettings({
360 crossingTimeKfzGeradeaus: 5,
361 crossingTimeKfzAbbiegend: 4,
362 });
363 const ergebnis = computeIntergreen(
364 raeumenderAbbieger({ ueberfahrzeitAnsatz: 'fest', crossingTimeOverride: 1 }),
365 EINFAHREND,
366 defaults,
367 );
368 const meldung = ergebnis.notes.find((n) => n.code === 'ueberfahrzeit-unter-gelbzeitschranke');
369 expect(meldung?.severity).toBe('fehler');
370 expect(meldung?.message).toContain('Regelwert von 4 s');
371 expect(meldung?.message).toContain('5 s geradeaus, 4 s abbiegend');
372 // Genau das stand vorher da - und widersprach dem Satzanfang.
373 expect(meldung?.message).not.toContain('(3 s geradeaus, 2 s abbiegend)');
374 });
375 });
376
377 // --- F1 und F2: Fehlerrichtungen des Schwerverkehrs ------------------------
378
379 describe('F1/F2 - die Groessenordnungen der Schwerverkehrsrechnung', () => {
380 it('bestaetigt, dass der Zuwachs gegenueber dem Altstand monoton bis 5,3 % waechst', () => {
381 const zuwachs = (pSV: number): number =>
382 2000 / schwerverkehrsfaktor(pSV) / (2000 / (1 + pSV)) - 1;
383 // Die Zahl, die als Obergrenze gedruckt wurde, ist keine.
384 expect(zuwachs(1)).toBeGreaterThan(0.05);
385 expect(zuwachs(1) * 100).toBeCloseTo(5.263, 3);
386 expect(zuwachs(0.3) * 100).toBeCloseTo(2.362, 3);
387 expect(zuwachs(0.05) * 100).toBeCloseTo(0.478, 3);
388 // Streng monoton wachsend - "bis zu" gilt am oberen Rand, nicht bei 30 %.
389 let vorher = -1;
390 for (let pSV = 0; pSV <= 1.0001; pSV += 0.05) {
391 const wert = zuwachs(Math.min(pSV, 1));
392 expect(wert).toBeGreaterThan(vorher);
393 vorher = wert;
394 }
395 });
396
397 it('haelt die Fehlerrichtung der fehlenden Aufteilung fest', () => {
398 const lastzug = (p: number): number =>
399 schwerverkehrsfaktor(0, { lkwUndBus: 0, lkwMitAnhaenger: p }) / schwerverkehrsfaktor(p) - 1;
400 expect(lastzug(0.2) * 100).toBeCloseTo(10.17, 2);
401 expect(lastzug(1) * 100).toBeCloseTo(31.58, 2);
402 expect(HBS_PKW_GLEICHWERTE.lkwMitAnhaenger).toBe(2.5);
403 });
404
405 it('druckt Richtung und Groessenordnung in der Planunterlage', () => {
406 const satz = schwerverkehrSatz();
407 expect(satz).toContain('ZU GROSS');
408 expect(satz).toContain('10,2 %');
409 expect(satz).toContain('31,6 %');
410 expect(satz).toContain('1.695 Fz/h');
411 expect(satz).toContain('1.538 Fz/h');
412 });
413
414 it('nennt im Fundstellenverzeichnis beide Fehlerrichtungen mit richtigen Grenzen', () => {
415 const hinweis = QUELLEN['hbs-kapazitaet'].hinweis ?? '';
416 expect(hinweis).toContain('5,3 %');
417 expect(hinweis).toContain('2,4 %');
418 expect(hinweis).toContain('31,6 %');
419 expect(hinweis).not.toContain('bis zu 2,3 %');
420 });
421 });
422
423 // --- F6: Referat statt Zitat ------------------------------------------------
424
425 describe('F6 - der erschlossene Schluss ist durch das Original ersetzt', () => {
426 it('nennt die Fundstelle statt des erschlossenen Umkehrschlusses', () => {
427 /*
428 * F6 verlangte, den Hamburger Leitfaden zu REFERIEREN und den daraus
429 * gezogenen Schluss ("die RiLSA kennt keinen Sonderwert") als erschlossen
430 * zu kennzeichnen. Das war richtig, solange nur der Leitfaden vorlag. Seit
431 * 5.27.0 liegt die RiLSA vor, und der Schluss war falsch: Abschnitt 2.5.2,
432 * Fall 2 nennt die 5 m/s samt Radiusschwelle. Ein erschlossener Schluss
433 * neben der Fundstelle waere jetzt der Fehler.
434 */
435 const quelle = QUELLEN['praxis-enger-radius'];
436 const hinweis = quelle.hinweis ?? '';
437 expect(hinweis).not.toContain('erschlossen, nicht ausgesprochen');
438 expect(quelle.fundstelle).toContain('2.5.2');
439 expect(quelle.pruefstand).toBe('gesichert');
440 /*
441 * Die alte Aussage bleibt im Text stehen, aber nur noch als Bericht ueber
442 * den frueheren Stand: Sie muss HINTER dem Satz kommen, der sie widerruft.
443 * Stuende sie davor, behauptete die Unterlage wieder, der Wert sei nicht
444 * nachweisbar.
445 */
446 expect(hinweis).toContain('ist widerlegt');
447 expect(hinweis.indexOf('nicht nachweisbar')).toBeGreaterThan(
448 hinweis.indexOf('Bis zur Fassung 5.26.0'),
449 );
450 });
451 });
452
453 // --- B6: gleiche Auskunft fuer beide bedingten Kennwerte -------------------
454
455 describe('B6 - Wirkungsvermerk auch fuer den engen Innenradius', () => {
456 it('meldet die Abweichung als wirkungslos, solange keine Beziehung das Merkmal traegt', () => {
457 const basis = createStandardIntersectionProject('Wirkungslos', DATUM);
458 const project: Project = actions.setSettings(basis, {
459 rilsa: { clearingSpeedKfzTurningEngerRadius: 4 },
460 });
461 const bericht = validateProject(project, buildSignalPlan(project), DATUM);
462 const befund = bericht.findings.find(
463 (f) => f.rule === 'vorgaben.abweichung.clearingSpeedKfzTurningEngerRadius',
464 );
465 expect(befund?.message).toContain('keine Beziehung dieses Merkmal');
466 expect(befund?.suggestion).not.toContain('in den Planunterlagen zu begründen');
467 });
468 });