lsa-planer

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

/ tests domain vierarmig.test.ts

14,4 KB Rohdatei
tests/domain/vierarmig.test.ts — 403 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung';
3 import { REGELBREITE } from '@/domain/geometrie/vermessung';
4 import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
5 import { createEmptyProject } from '@/domain/model/factory';
6 import { buildSignalPlan } from '@/domain/plan/signalPlan';
7 import { validateProject } from '@/domain/validation/engine';
8 import type { Movement } from '@/domain/rilsa/types';
9 import type { Project } from '@/domain/model/project';
10
11 /**
12 * Vollstaendiger vierarmiger Knotenpunkt - die Gesamtkontrolle.
13 *
14 * Vier Zufahrten mit je geradeaus, links und rechts, dazu vier
15 * Fussgaengerfurten: sechzehn Stroeme an acht Haltlinien. Das ist der Fall,
16 * den die Aenderung tragen muss, und er wird hier vollstaendig durchgerechnet -
17 * von der Zeichnung bis zum ausgabefaehigen Signalzeitenplan.
18 *
19 * Die Geometrie steht als Vorschrift, nicht als Zahlenliste: Jede Zufahrt
20 * entsteht aus derselben Formel, nur gedreht. Dadurch laesst sich jede Zahl von
21 * Hand nachrechnen - und der Fall bleibt lesbar.
22 *
23 * MASSE: Rechtsverkehr, Fahrbahn 14 m, Fahrstreifen 3,5 m, Haltlinie 12 m und
24 * Furt 9 m vor der Knotenpunktmitte. 1 Bildpunkt = 0,1 m.
25 */
26
27 const FIXED_DATE = new Date('2026-01-01T00:00:00Z');
28
29 const KANTE = 90;
30 const PX = 10;
31 const MITTE = { x: 45, y: 45 };
32 const HALB = 7;
33 const ACHSE_INNEN = 1.75;
34 const ACHSE_AUSSEN = 5.25;
35 const R_LINKS = 12;
36 const R_RECHTS = 6;
37 const HALTLINIE_VOR = 12;
38 const FURT_VOR = 9;
39 const AUSLAUF = 40;
40
41 type Vektor = { x: number; y: number };
42
43 /** Rechts des Fahrers - bei y nach unten ist das (-fy, fx). */
44 const rechts = (v: Vektor): Vektor => ({ x: -v.y, y: v.x });
45
46 const plus = (a: Vektor, ...teile: readonly { v: Vektor; s: number }[]): Vektor =>
47 teile.reduce((p, t) => ({ x: p.x + t.v.x * t.s, y: p.y + t.v.y * t.s }), a);
48
49 const px = (p: Vektor): Vektor => ({ x: p.x * PX, y: p.y * PX });
50
51 /** Kreisbogen - Abbiegevorgaenge als Eckzug erzeugen Scheinkonflikte. */
52 function bogen(mittelpunkt: Vektor, von: Vektor, bis: Vektor, stuecke = 8): Vektor[] {
53 const radius = Math.hypot(von.x - mittelpunkt.x, von.y - mittelpunkt.y);
54 const a0 = Math.atan2(von.y - mittelpunkt.y, von.x - mittelpunkt.x);
55 let a1 = Math.atan2(bis.y - mittelpunkt.y, bis.x - mittelpunkt.x);
56 while (a1 - a0 > Math.PI) a1 -= 2 * Math.PI;
57 while (a0 - a1 > Math.PI) a1 += 2 * Math.PI;
58
59 const punkte: Vektor[] = [];
60 for (let i = 0; i <= stuecke; i += 1) {
61 const a = a0 + ((a1 - a0) * i) / stuecke;
62 punkte.push({
63 x: mittelpunkt.x + radius * Math.cos(a),
64 y: mittelpunkt.y + radius * Math.sin(a),
65 });
66 }
67 return punkte;
68 }
69
70 const ARME = [
71 { id: 'arm-nord', name: 'Nord', f: { x: 0, y: 1 } },
72 { id: 'arm-ost', name: 'Ost', f: { x: -1, y: 0 } },
73 { id: 'arm-sued', name: 'Sued', f: { x: 0, y: -1 } },
74 { id: 'arm-west', name: 'West', f: { x: 1, y: 0 } },
75 ] as const;
76
77 /** `mitHaltlinien` steuert, ob die Stroeme ihrer Zufahrt zugeordnet sind. */
78 function knotenpunkt(mitHaltlinien: boolean): Lageplan {
79 const haltlinien: Haltlinie[] = [];
80 const linien: Planlinie[] = [];
81
82 for (const arm of ARME) {
83 const f = arm.f as Vektor;
84 const r = rechts(f);
85 const rr = rechts(r);
86
87 const aufAchse = plus(MITTE, { v: f, s: -HALTLINIE_VOR });
88 const haltlinie: Haltlinie = {
89 id: `hl-${arm.id}`,
90 name: arm.name,
91 art: 'zufahrt',
92 armId: arm.id,
93 von: px(aufAchse),
94 bis: px(plus(aufAchse, { v: r, s: HALB })),
95 };
96 haltlinien.push(haltlinie);
97
98 const startAussen = plus(aufAchse, { v: r, s: ACHSE_AUSSEN });
99 const startInnen = plus(aufAchse, { v: r, s: ACHSE_INNEN });
100
101 const stroeme: readonly { movement: Movement; startT: number; punkte: readonly Vektor[] }[] = [
102 {
103 movement: 'geradeaus',
104 startT: ACHSE_AUSSEN / HALB,
105 punkte: [
106 startAussen,
107 plus(MITTE, { v: f, s: FURT_VOR }, { v: r, s: ACHSE_AUSSEN }),
108 plus(MITTE, { v: f, s: AUSLAUF }, { v: r, s: ACHSE_AUSSEN }),
109 ],
110 },
111 {
112 movement: 'rechts',
113 startT: ACHSE_AUSSEN / HALB,
114 punkte: [
115 startAussen,
116 ...bogen(
117 plus(
118 MITTE,
119 { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) },
120 { v: r, s: ACHSE_AUSSEN + R_RECHTS },
121 ),
122 plus(MITTE, { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, { v: r, s: ACHSE_AUSSEN }),
123 plus(MITTE, { v: r, s: ACHSE_AUSSEN + R_RECHTS }, { v: rr, s: ACHSE_AUSSEN }),
124 ),
125 plus(MITTE, { v: r, s: AUSLAUF }, { v: rr, s: ACHSE_AUSSEN }),
126 ],
127 },
128 {
129 movement: 'links',
130 startT: ACHSE_INNEN / HALB,
131 punkte: [
132 startInnen,
133 ...bogen(
134 plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN - R_LINKS }),
135 plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN }),
136 plus(MITTE, { v: f, s: ACHSE_INNEN }, { v: r, s: ACHSE_INNEN - R_LINKS }),
137 ),
138 plus(MITTE, { v: r, s: -AUSLAUF }, { v: rr, s: -ACHSE_INNEN }),
139 ],
140 },
141 ];
142
143 for (const strom of stroeme) {
144 linien.push({
145 id: `${arm.id}-${strom.movement}`,
146 name: `${arm.name} ${strom.movement}`,
147 mode: 'kfz',
148 movement: strom.movement,
149 breiteMeter: REGELBREITE.kfz,
150 punkte: strom.punkte.map(px),
151 haltlinieId: mitHaltlinien ? haltlinie.id : null,
152 startT: strom.startT,
153 signalGroupId: null,
154 });
155 }
156
157 const furtAchse = plus(MITTE, { v: f, s: -FURT_VOR });
158 const bordA = plus(furtAchse, { v: r, s: HALB });
159 const furtHalt: Haltlinie = {
160 id: `hl-furt-${arm.id}`,
161 name: `Furt ${arm.name}`,
162 art: 'querung',
163 armId: arm.id,
164 von: px(plus(bordA, { v: f, s: -2 })),
165 bis: px(plus(bordA, { v: f, s: 2 })),
166 };
167 haltlinien.push(furtHalt);
168 linien.push({
169 id: `${arm.id}-furt`,
170 name: `Furt ${arm.name}`,
171 mode: 'fuss',
172 movement: 'querung',
173 breiteMeter: REGELBREITE.fuss,
174 punkte: [px(bordA), px(plus(furtAchse, { v: r, s: -HALB }))],
175 haltlinieId: mitHaltlinien ? furtHalt.id : null,
176 startT: 0.5,
177 signalGroupId: null,
178 });
179 }
180
181 return {
182 arbeitsbereiche: [],
183 signalgeber: [],
184 bild: {
185 datenUrl: 'data:image/png;base64,AAAA',
186 breite: KANTE * PX,
187 hoehe: KANTE * PX,
188 herkunft: 'Pruefstueck',
189 geladenAm: FIXED_DATE.toISOString(),
190 },
191 kalibrierung: {
192 von: { x: 0, y: 0 },
193 bis: { x: KANTE * PX, y: 0 },
194 laengeMeter: KANTE,
195 herkunft: 'georeferenziert',
196 },
197 haltlinien,
198 linien,
199 };
200 }
201
202 function projektMitArmen(): Project {
203 const leer = createEmptyProject('Vierarmig', FIXED_DATE);
204 return {
205 ...leer,
206 intersection: {
207 ...leer.intersection,
208 arms: ARME.map((a) => ({ id: a.id, name: a.name, direction: a.name, lanes: 2, vZul: 50 })),
209 },
210 };
211 }
212
213 const zufahrt = (id: string): string => id.split('-')[1] ?? '';
214 const istFurt = (id: string): boolean => id.endsWith('-furt');
215
216 describe('Vierarmiger Knotenpunkt, vollstaendig', () => {
217 const mit = leiteAb(projektMitArmen(), knotenpunkt(true));
218 const ohne = leiteAb(projektMitArmen(), knotenpunkt(false));
219
220 const kfzPaareDerselbenZufahrt = (a: typeof mit) =>
221 a.vertraeglichkeit.filter(
222 (v) =>
223 zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && !istFurt(v.aLinieId) && !istFurt(v.bLinieId),
224 );
225
226 it('zeichnet sechzehn Stroeme an acht Haltlinien', () => {
227 const plan = knotenpunkt(true);
228 expect(plan.linien).toHaveLength(16);
229 expect(plan.haltlinien).toHaveLength(8);
230 });
231
232 it('meldet keinen einzigen Konflikt innerhalb einer Zufahrt', () => {
233 const paare = kfzPaareDerselbenZufahrt(mit);
234 expect(paare).toHaveLength(12);
235 expect(paare.filter((v) => v.feindlich)).toHaveLength(0);
236 });
237
238 it('meldet ohne Haltlinien Scheinkonflikte innerhalb der Zufahrten', () => {
239 // Der Zustand vor der Aenderung, und der jedes uebernommenen Projekts.
240 // Er wird festgehalten, damit der Gewinn messbar bleibt: Ohne Zuordnung
241 // gelten die gemeinsamen Anfangspunkte als Kreuzungen.
242 const falsch = kfzPaareDerselbenZufahrt(ohne).filter((v) => v.feindlich);
243 expect(falsch.length).toBeGreaterThan(0);
244 // Mit Haltlinien ist keiner davon uebrig.
245 expect(kfzPaareDerselbenZufahrt(mit).filter((v) => v.feindlich)).toHaveLength(0);
246 });
247
248 it('haelt jede Furt gegenueber den Stroemen ihres eigenen Arms feindlich', () => {
249 // Die Furt gehoert zum Arm, nicht zu dessen Zufahrt. Wuerde die Ausnahme
250 // hier greifen, bekaeme der Fussgaenger gleichzeitig Gruen mit dem
251 // Fahrzeugstrom, der ihn quert.
252 const paare = mit.vertraeglichkeit.filter(
253 (v) =>
254 zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && (istFurt(v.aLinieId) || istFurt(v.bLinieId)),
255 );
256 expect(paare).toHaveLength(12);
257 expect(paare.every((v) => v.feindlich)).toBe(true);
258 });
259
260 it('verliert keinen Konflikt zwischen verschiedenen Zufahrten', () => {
261 const echt = (a: typeof mit) =>
262 a.vertraeglichkeit
263 .filter((v) => v.feindlich && zufahrt(v.aLinieId) !== zufahrt(v.bLinieId))
264 .map((v) => `${v.aLinieId}|${v.bLinieId}`)
265 .sort();
266 expect(echt(mit)).toEqual(echt(ohne));
267 expect(echt(mit).length).toBeGreaterThan(25);
268 });
269
270 it('erkennt geradeaus und rechts als gemeinsamen Fahrstreifen', () => {
271 // Beide beginnen auf demselben aeusseren Fahrstreifen. Sie sind
272 // vertraeglich, muessen aber denselben Signalgeber bekommen.
273 const paar = mit.vertraeglichkeit.find(
274 (v) => v.aLinieId === 'arm-nord-geradeaus' && v.bLinieId === 'arm-nord-rechts',
275 );
276 expect(paar?.feindlich).toBe(false);
277 expect(paar?.grund).toBe('auffaecherung');
278 });
279
280 it('haelt die gegenueberliegenden Linksabbieger auseinander', () => {
281 // Sie fahren hintereinander um die Knotenpunktmitte herum. Als Eckzug
282 // gezeichnet schnitten ihre Sehnen sich - der Bogen loest das.
283 const paar = mit.vertraeglichkeit.find(
284 (v) => v.aLinieId === 'arm-nord-links' && v.bLinieId === 'arm-sued-links',
285 );
286 expect(paar?.feindlich).toBe(false);
287 });
288
289 it('rechnet Raeum- und Einfahrweg nachpruefbar', () => {
290 // Nord geradeaus liegt bei x = 39,75 und beginnt bei y = 33.
291 // West geradeaus liegt bei y = 50,25 und beginnt bei x = 33.
292 // Schnittpunkt (39,75 | 50,25): 17,25 m entlang Nord, 6,75 m entlang West.
293 // Raeumweg = 17,25 + 3,25/2 = 18,875
294 // Einfahrweg = 6,75 - 3,25/2 = 5,125
295 const weg = mit.wege.find(
296 (w) => w.vonLinieId === 'arm-nord-geradeaus' && w.nachLinieId === 'arm-west-geradeaus',
297 );
298 expect(weg?.raeumweg).toBeCloseTo(18.88, 2);
299 expect(weg?.einfahrweg).toBeCloseTo(5.13, 2);
300 expect(weg?.winkelGrad).toBeCloseTo(90, 1);
301 });
302
303 it('traegt den Plan bis zum ausgabefaehigen Signalzeitenplan', () => {
304 // Die ganze Kette: buendeln, uebernehmen, Phasen bilden, pruefen.
305 let p = projektMitArmen();
306 p = { ...p, lageplan: knotenpunkt(true) };
307
308 const gruppeVon = new Map<string, string>();
309 const gruppen = [];
310 for (const arm of ARME) {
311 // Ein Signalgeber fuer geradeaus UND rechts - sie teilen den Fahrstreifen.
312 for (const [suffix, linien] of [
313 ['', [`${arm.id}-geradeaus`, `${arm.id}-rechts`]],
314 ['-links', [`${arm.id}-links`]],
315 ['-furt', [`${arm.id}-furt`]],
316 ] as const) {
317 const id = `sg-${arm.id}${suffix}`;
318 const istFuss = suffix === '-furt';
319 gruppen.push({
320 id,
321 name: `${istFuss ? 'F' : 'K'} ${arm.name}${suffix === '-links' ? ' links' : ''}`,
322 mode: istFuss ? ('fuss' as const) : ('kfz' as const),
323 movement: istFuss
324 ? ('querung' as const)
325 : suffix === '-links'
326 ? ('links' as const)
327 : ('geradeaus' as const),
328 armId: arm.id,
329 vZul: 50,
330 lanes: 1,
331 // Fussgaenger haben keine Fahrzeuglaenge - dafuer steht 'keine'.
332 vehicleClass: istFuss ? ('keine' as const) : ('pkw' as const),
333 minGreenOverride: null,
334 maxGreenOverride: null,
335 reducedMobility: false,
336 color: '#2f6fd0',
337 });
338 for (const l of linien) gruppeVon.set(l, id);
339 }
340 }
341
342 p = {
343 ...p,
344 signalGroups: gruppen,
345 lageplan: {
346 ...p.lageplan,
347 linien: p.lageplan.linien.map((l) => ({
348 ...l,
349 signalGroupId: gruppeVon.get(l.id) ?? null,
350 })),
351 },
352 };
353
354 const abgeleitet = leiteAb(p, p.lageplan);
355 p = uebernimmWege(p, p.lageplan, abgeleitet, abgeleitet.wege.map(wegSchluessel)).project;
356
357 // Kein Selbstkonflikt, obwohl zwei Stroeme je Gruppe gebuendelt sind.
358 expect(p.conflicts.filter((c) => c.fromId === c.toId)).toHaveLength(0);
359 // Und jede Gruppenbeziehung genau einmal, nicht je Strompaar einmal.
360 const paare = p.conflicts.map((c) => `${c.fromId}|${c.toId}`);
361 expect(new Set(paare).size).toBe(paare.length);
362
363 const phasen = [
364 { id: 'ph-1', gruppen: ['sg-arm-nord', 'sg-arm-sued'] },
365 { id: 'ph-2', gruppen: ['sg-arm-nord-links', 'sg-arm-sued-links'] },
366 { id: 'ph-3', gruppen: ['sg-arm-ost', 'sg-arm-west'] },
367 { id: 'ph-4', gruppen: ['sg-arm-ost-links', 'sg-arm-west-links'] },
368 { id: 'ph-5', gruppen: ARME.map((a) => `sg-${a.id}-furt`) },
369 ].map((ph) => ({
370 id: ph.id,
371 name: ph.id,
372 signalGroupIds: ph.gruppen,
373 manualGreen: null,
374 }));
375
376 p = {
377 ...p,
378 phases: phasen,
379 program: { ...p.program, phaseOrder: phasen.map((ph) => ph.id) },
380 demands: p.signalGroups.map((g) => ({
381 signalGroupId: g.id,
382 // Massvolle Nachfrage: Der Fall prueft die Geometrie und die Kette bis
383 // zum Signalzeitenplan, nicht die Leistungsfaehigkeit. Bei fuenf Phasen
384 // und 5 % Schwerverkehr passt sie in eine Umlaufzeit unter 120 s.
385 volume: g.mode === 'fuss' ? 120 : 160,
386 heavyVehicleShare: g.mode === 'fuss' ? 0 : 0.05,
387 })),
388 };
389
390 const plan = buildSignalPlan(p);
391 const bericht = validateProject(p, plan, FIXED_DATE);
392
393 // Ueber die Meldungen und nicht ueber die Anzahl: Schlaegt der Fall fehl,
394 // steht der Grund im Fehlertext, statt dass nur eine Zahl nicht stimmt.
395 expect(bericht.findings.filter((f) => f.severity === 'fehler').map((f) => f.message)).toEqual(
396 [],
397 );
398 expect(bericht.exportBlocked).toBe(false);
399 expect(plan.cycleTime).toBeGreaterThan(30);
400 expect(plan.cycleTime).toBeLessThanOrEqual(120);
401 expect(plan.feasible).toBe(true);
402 });
403 });