lsa-planer

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

/ tests domain messgrundlage.test.ts

13,7 KB Rohdatei
tests/domain/messgrundlage.test.ts — 342 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { messeignungSatz, messgrundlageBefunde } from '@/domain/validation/rules';
3 import { validateProject } from '@/domain/validation/engine';
4 import { messgenauigkeit, type Lageplan, type Planlinie } from '@/domain/geometrie/lageplan';
5 import { RILSA_DEFAULTS, type RilsaDefaults } from '@/domain/rilsa/constants';
6 import { createSignalGroup, createStandardIntersectionProject } from '@/domain/model/factory';
7 import type { Project } from '@/domain/model/project';
8
9 /**
10 * Ist die Bildunterlage zu grob fuer das, was daraus vermessen wurde?
11 *
12 * Der Schaden liegt in der unsicheren Richtung: Aus einem groben Ausschnitt
13 * abgegriffen, faellt der Raeumweg einer Fussgaengerfurt zu kurz aus, und damit
14 * die Zwischenzeit. Dem Signalzeitenplan sieht man das nicht an - er bleibt
15 * formal widerspruchsfrei. Geprueft wird deshalb vor allem, dass die Meldung
16 * ueberhaupt kommt, dass sie die Zahl nennt, an der sie haengt, und dass sie
17 * nicht kommt, wo das Modell nichts weiss.
18 */
19
20 const FIXED_DATE = new Date('2026-01-01T00:00:00Z');
21
22 /** Massstaebe, an denen die Einstufung kippt - siehe messgenauigkeit.test.ts. */
23 const FEIN = 0.05; // 5 cm je Bildpunkt: auch die Furt ist abgreifbar
24 const GROB = 2000 / 7000; // 28,6 cm je Bildpunkt: 2000 m Ausschnitt, nur Kfz
25 const SEHR_GROB = 1.0; // 1 m je Bildpunkt: reines Uebersichtsbild
26
27 /**
28 * Genau auf der Fussgaenger-Schranke: 0,25 s / (2 * (1/1,0 + 1/11,1)) =
29 * 0,114669 m je Bildpunkt. Der Fussgaenger raeumt (mobilitaetseingeschraenkt,
30 * 1,0 m/s), das Kraftfahrzeug faehrt ein (11,1 m/s). Diese Unterlage ist gerade
31 * noch "voll" geeignet - und kippt, sobald ein Kennwert langsamer angesetzt wird.
32 */
33 const AN_DER_SCHRANKE = 0.25 / (2 * (1 / 1.0 + 1 / 11.1));
34
35 /** Fahrlinie mit zwei Punkten - damit ist der Lageplan auswertbar. */
36 function fahrlinie(id: string): Planlinie {
37 return {
38 id,
39 name: id,
40 mode: 'kfz',
41 movement: 'geradeaus',
42 breiteMeter: 3.25,
43 punkte: [
44 { x: 10, y: 10 },
45 { x: 10, y: 90 },
46 ],
47 haltlinieId: null,
48 startT: 0.5,
49 signalGroupId: null,
50 };
51 }
52
53 /**
54 * Lageplan mit einem Massstab von `meterJeBildpunkt`.
55 * Die Bezugsstrecke ist 100 Bildpunkte lang; `gezeichnet` entscheidet, ob auf
56 * der Unterlage bereits eine Fahrlinie liegt.
57 */
58 function lageplan(meterJeBildpunkt: number, gezeichnet: boolean): Lageplan {
59 return {
60 arbeitsbereiche: [],
61 signalgeber: [],
62 bild: {
63 datenUrl: 'data:image/png;base64,AAAA',
64 breite: 7000,
65 hoehe: 7000,
66 herkunft: 'Pruefbild',
67 geladenAm: '2026-03-04T09:15:00.000Z',
68 },
69 kalibrierung: {
70 von: { x: 0, y: 0 },
71 bis: { x: 100, y: 0 },
72 laengeMeter: meterJeBildpunkt * 100,
73 herkunft: 'georeferenziert',
74 },
75 haltlinien: [],
76 linien: gezeichnet ? [fahrlinie('l1')] : [],
77 };
78 }
79
80 /** Regelknotenpunkt (vier Kfz-Gruppen, zwei Fussgaengerfurten) mit Unterlage. */
81 function mitLageplan(meterJeBildpunkt: number, gezeichnet = true): Project {
82 const basis = createStandardIntersectionProject('Prueffall', FIXED_DATE);
83 return { ...basis, lageplan: lageplan(meterJeBildpunkt, gezeichnet) };
84 }
85
86 /** Dasselbe Projekt, aber ohne Fussgaenger- und Radsignalgruppen. */
87 function nurKfzGruppen(project: Project): Project {
88 const behalten = project.signalGroups.filter((g) => g.mode !== 'fuss' && g.mode !== 'rad');
89 const ids = new Set(behalten.map((g) => g.id));
90 return {
91 ...project,
92 signalGroups: behalten,
93 conflicts: project.conflicts.filter((c) => ids.has(c.fromId) && ids.has(c.toId)),
94 phases: project.phases.map((p) => ({
95 ...p,
96 signalGroupIds: p.signalGroupIds.filter((id) => ids.has(id)),
97 })),
98 };
99 }
100
101 describe('Regel: Eignung der Bildunterlage', () => {
102 it('schweigt ohne Massstab, statt eine Eignung zu behaupten', () => {
103 // Ohne Kalibrierung ist unbekannt, wie viel ein Bildpunkt am Boden misst.
104 // Eine Meldung ueber eine Sicherheitsgroesse aus einer erfundenen Zahl
105 // waere schlimmer als keine Meldung.
106 const project = mitLageplan(SEHR_GROB);
107 const ohneMassstab: Project = {
108 ...project,
109 lageplan: { ...project.lageplan, kalibrierung: null },
110 };
111
112 expect(messgrundlageBefunde(ohneMassstab)).toHaveLength(0);
113 });
114
115 it('schweigt bei einer Unterlage, die auch fuer Furten reicht', () => {
116 expect(messgenauigkeit(lageplan(FEIN, true))!.eignung).toBe('voll');
117 expect(messgrundlageBefunde(mitLageplan(FEIN))).toHaveLength(0);
118 });
119
120 it('meldet einen Fehler, wenn nur Kfz abgreifbar sind, die Furt gefuehrt und gezeichnet wird', () => {
121 // Befund A4: Sobald hier abgegriffen wird (Massstab und Fahrlinie
122 // vorhanden), wirkt derselbe Wegfehler an der Furt fast zehnfach staerker
123 // als bei Kfz - derselbe Vorgang, der im Uebersichtsfall sperrt, darf hier
124 // nicht bei einer Warnung bleiben.
125 const befunde = messgrundlageBefunde(mitLageplan(GROB));
126
127 expect(befunde).toHaveLength(1);
128 expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-nur-kfz');
129 expect(befunde[0]!.severity).toBe('fehler');
130 expect(befunde[0]!.category).toBe('zwischenzeiten');
131 // Die betroffenen Gruppen stehen in der Meldung - sonst muss der Bearbeiter
132 // raten, welche Beziehungen er nachmessen soll.
133 expect(befunde[0]!.message).toContain('F1');
134 expect(befunde[0]!.message).toContain('F2');
135 });
136
137 it('warnt nur, solange auf der groben Unterlage noch nichts gezeichnet ist', () => {
138 // Ohne Fahrlinie wird noch nicht abgegriffen; die Warnung meldet den
139 // Vorgang, bevor er beginnt - wie im Uebersichtsfall, der ohne Zeichnung
140 // gar nichts meldet.
141 const befunde = messgrundlageBefunde(mitLageplan(GROB, false));
142
143 expect(befunde).toHaveLength(1);
144 expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-nur-kfz');
145 expect(befunde[0]!.severity).toBe('warnung');
146 });
147
148 it('warnt auch fuer eine Radsignalgruppe', () => {
149 const basis = mitLageplan(GROB);
150 const ohneFuss = nurKfzGruppen(basis);
151 const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 6 });
152 const mitRad: Project = { ...ohneFuss, signalGroups: [...ohneFuss.signalGroups, rad] };
153
154 expect(messgrundlageBefunde(mitRad).map((f) => f.rule)).toEqual([
155 'zwischenzeiten.messgrundlage-nur-kfz',
156 ]);
157 });
158
159 it('warnt nicht, wenn das Projekt nur Kraftfahrzeugstroeme fuehrt', () => {
160 // Eine reine Kfz-Anlage darf auf einem Uebersichtsausschnitt der Gruenen
161 // Welle vermessen werden - dafuer reicht die Aufloesung.
162 expect(messgrundlageBefunde(nurKfzGruppen(mitLageplan(GROB)))).toHaveLength(0);
163 });
164
165 it('meldet einen Fehler, sobald auf einem Uebersichtsbild gezeichnet ist', () => {
166 const befunde = messgrundlageBefunde(mitLageplan(SEHR_GROB));
167
168 expect(befunde).toHaveLength(1);
169 expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-uebersicht');
170 expect(befunde[0]!.severity).toBe('fehler');
171 });
172
173 it('meldet den Fehler nicht, solange auf der Unterlage nichts gezeichnet ist', () => {
174 // Das Ersatzmerkmal: Ob ein eingetragener Raeumweg aus dem Lageplan stammt,
175 // fuehrt das Modell nicht mit. Eine gezeichnete Fahrlinie hat aber keinen
176 // anderen Zweck als das Vermessen - fehlt sie, ist nichts abgegriffen
177 // worden, und ein Uebersichtsbild allein ist noch kein Fehler.
178 const befunde = messgrundlageBefunde(mitLageplan(SEHR_GROB, false));
179
180 expect(befunde).toHaveLength(0);
181 });
182
183 it('schliesst Warnung und Fehler gegeneinander aus', () => {
184 // Die Eignung ist genau ein Wert. Zwei Meldungen zur selben Unterlage
185 // wuerden einander widersprechen.
186 for (const massstab of [FEIN, GROB, SEHR_GROB]) {
187 const rules = messgrundlageBefunde(mitLageplan(massstab)).map((f) => f.rule);
188 expect(new Set(rules).size).toBe(rules.length);
189 expect(rules.length).toBeLessThanOrEqual(1);
190 }
191 });
192
193 it('nennt die Zahl, an der der Befund haengt', () => {
194 const befund = messgrundlageBefunde(mitLageplan(GROB))[0]!;
195
196 // 2000 m auf 7000 Bildpunkten: 28,6 cm je Bildpunkt. Der Zeichenfehler
197 // von zwei Bildpunkten kostet an der Furt
198 // 2 * 0,285714 m * (1/1,0 + 1/11,1) = 0,622909 s, ausgegeben mit zwei
199 // Nachkommastellen - das Zweieinhalbfache der hinnehmbaren 0,25 s.
200 expect(befund.message).toContain('28,6 cm je Bildpunkt');
201 expect(befund.message).toContain('0,62 s');
202 expect(befund.message).toContain('0,25 s');
203 });
204
205 it('nennt beide Auswege und eine Fundstelle', () => {
206 for (const massstab of [GROB, SEHR_GROB]) {
207 const befund = messgrundlageBefunde(mitLageplan(massstab))[0]!;
208 expect(befund.suggestion).toContain('engeren Ausschnitt');
209 expect(befund.suggestion).toContain('von Hand');
210 expect(befund.reference).toBe('RiLSA 2015 - Zwischenzeiten');
211 expect(befund.title).not.toBe('');
212 expect(befund.target).not.toBeNull();
213 }
214 });
215
216 it('folgt angepassten Kennwerten, statt sie ein zweites Mal zu fuehren', () => {
217 // Langsamer angesetzte Raeumgeschwindigkeiten vergroessern den Zeitfehler.
218 // 53 cm je Bildpunkt liegen knapp unter der Kfz-Schranke; wird abbiegend
219 // mit 5,0 statt 7,0 m/s geraeumt - ein zulaessiger Wert -, reicht die
220 // Unterlage nicht einmal mehr fuer Kfz, und die Einstufung faellt von
221 // nur-kfz auf Uebersicht. (Seit Befund A4 sind beide Stufen bei
222 // bezeichneter Unterlage Fehler; unterschieden wird an der Regel.)
223 const langsamer: RilsaDefaults = {
224 ...RILSA_DEFAULTS,
225 clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, kfzTurning: 5 },
226 };
227 const project = mitLageplan(0.53);
228
229 expect(messgrundlageBefunde(project).map((f) => f.rule)).toEqual([
230 'zwischenzeiten.messgrundlage-nur-kfz',
231 ]);
232 expect(messgrundlageBefunde(project, langsamer).map((f) => f.rule)).toEqual([
233 'zwischenzeiten.messgrundlage-uebersicht',
234 ]);
235 });
236 });
237
238 describe('Befund in einem Satz', () => {
239 it('behauptet ohne Massstab keine Eignung', () => {
240 expect(messeignungSatz(null)).toContain('Nicht beurteilbar');
241 });
242
243 it('gibt fuer jede Einstufung denselben Zahlensatz aus', () => {
244 for (const massstab of [FEIN, GROB, SEHR_GROB]) {
245 const satz = messeignungSatz(messgenauigkeit(lageplan(massstab, true)));
246 expect(satz).toContain('cm je Bildpunkt');
247 expect(satz).toContain('Fußgängerfurt');
248 expect(satz).toContain('0,25 s');
249 }
250 });
251
252 it('erfindet bei einer Geschwindigkeit von 0 keine Zahl', () => {
253 // Ein solcher Kennwertsatz kommt aus der Vorgabenpruefung nicht heraus.
254 // Falls doch: "Infinity s" waere im Pruefbericht sinnlos.
255 //
256 // Auf 0 gesetzt wird die Raeumgeschwindigkeit, die der Fussgaengerfehler
257 // liest - die des mobilitaetseingeschraenkten Fussgaengers. Ueber
258 // clearingSpeed.fuss ist der entartete Fall nicht mehr erreichbar.
259 const entartet: RilsaDefaults = {
260 ...RILSA_DEFAULTS,
261 clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, fussReducedMobility: 0 },
262 };
263 const satz = messeignungSatz(messgenauigkeit(lageplan(FEIN, true), entartet));
264
265 expect(satz).toContain('nicht bestimmbaren Betrag');
266 expect(satz).not.toContain('Infinity');
267 });
268 });
269
270 describe('Die Regel im Pruefbericht', () => {
271 it('sperrt den Export, wenn auf einem Uebersichtsbild vermessen wurde', () => {
272 const bericht = validateProject(mitLageplan(SEHR_GROB), undefined, FIXED_DATE);
273 const befund = bericht.findings.find(
274 (f) => f.rule === 'zwischenzeiten.messgrundlage-uebersicht',
275 );
276
277 expect(befund).toBeDefined();
278 expect(bericht.exportBlocked).toBe(true);
279 });
280
281 it('sperrt den Export, wenn die fuer Furten zu grobe Unterlage bezeichnet ist', () => {
282 // Befund A4: Vor Fassung 5.4.0 blieb dieser Fall eine
283 // Warnung, und ein Plan mit moeglicherweise verkuerzten Furt-Zwischenzeiten
284 // blieb ausgabefaehig.
285 const bericht = validateProject(mitLageplan(GROB), undefined, FIXED_DATE);
286
287 expect(
288 bericht.findings.some(
289 (f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz' && f.severity === 'fehler',
290 ),
291 ).toBe(true);
292 expect(bericht.exportBlocked).toBe(true);
293 });
294
295 it('sperrt den Export bei einer blossen Warnung nicht', () => {
296 const bericht = validateProject(mitLageplan(GROB, false), undefined, FIXED_DATE);
297
298 expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz')).toBe(
299 true,
300 );
301 expect(bericht.findings.some((f) => f.severity === 'fehler')).toBe(false);
302 });
303
304 it('reicht die Kennwerte des Projekts an die Regel durch', () => {
305 // Der Pruefbericht rechnet mit den Vorgaben des Projekts. Setzt der
306 // Bearbeiter die Fussgaengergeschwindigkeit herab, verschaerft sich die
307 // Anforderung an das Bild - eine bis dahin volle Eignung faellt auf nur-kfz.
308 //
309 // Herabgesetzt wird der Kennwert, den die Einstufung liest: die
310 // Raeumgeschwindigkeit des mobilitaetseingeschraenkten Fussgaengers. Auf
311 // der Schrankenunterlage wachsen die 0,25 s auf
312 // 2 * 0,114669 * (1/0,6 + 1/11,1) = 0,402893 s.
313 const project = mitLageplan(AN_DER_SCHRANKE);
314 const streng: Project = {
315 ...project,
316 settings: { ...project.settings, rilsa: { clearingSpeedFussReducedMobility: 0.6 } },
317 };
318
319 expect(
320 validateProject(project, undefined, FIXED_DATE).findings.some((f) =>
321 f.rule.startsWith('zwischenzeiten.messgrundlage'),
322 ),
323 ).toBe(false);
324 expect(
325 validateProject(streng, undefined, FIXED_DATE).findings.some(
326 (f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz',
327 ),
328 ).toBe(true);
329 });
330
331 it('laesst ein Projekt ohne Lageplan unberuehrt', () => {
332 const bericht = validateProject(
333 createStandardIntersectionProject('Ohne Unterlage', FIXED_DATE),
334 undefined,
335 FIXED_DATE,
336 );
337
338 expect(bericht.findings.some((f) => f.rule.startsWith('zwischenzeiten.messgrundlage'))).toBe(
339 false,
340 );
341 });
342 });