lsa-planer

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

/ tests domain koordinierungKette.test.ts

14,6 KB Rohdatei
tests/domain/koordinierungKette.test.ts — 346 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { createStandardIntersectionProject } from '@/domain/model/factory';
4 import { buildSignalPlan } from '@/domain/plan/signalPlan';
5 import { koordinierungslage } from '@/domain/plan/koordinierung';
6 import { RILSA_DEFAULTS } from '@/domain/rilsa/constants';
7 import { loadProject } from '@/domain/model/migrate';
8 import type { Project } from '@/domain/model/project';
9
10 /**
11 * Fassung 5.35.0 - die Koordinierung im Strassenzug.
12 *
13 * DREI ERWEITERUNGEN VERLANGT DER BEFUND, und diese Datei bewacht alle drei:
14 * die LAGE des Bandes (nicht nur seine Breite), die AUFSUMMIERTE Entfernung
15 * ueber die Kette und die ZWEITE Fahrtrichtung. Eine Richtung ist die halbe
16 * Arbeit und die halbe Aussage - der Zielkonflikt zwischen Hin- und
17 * Rueckrichtung ist der eigentliche Gegenstand der Koordinierung.
18 *
19 * DAZU DIE UNVERHANDELBARE BEDINGUNG: Gerechnet wird mit den Kennwerten DIESES
20 * Projekts, nicht mit den Regelwerten.
21 */
22
23 const STICHTAG = new Date('2026-01-01T12:00:00Z');
24
25 function basis(): Project {
26 return createStandardIntersectionProject('Hauptstraße / Bahnhofstraße', STICHTAG);
27 }
28
29 /**
30 * Ein Knotenpunkt mit fester Umlaufzeit 60 s und zwei Nachbarn.
31 *
32 * Feste Umlaufzeit, damit die Bandlage nachrechenbar bleibt: Sie ist der
33 * Bezugspunkt jeder Zahl dieser Datei.
34 */
35 function mitKette(optionen: { gegenrichtung?: boolean } = {}): Project {
36 const roh = basis();
37 const k1 = roh.signalGroups[0]!.id;
38 const k2 = roh.signalGroups[1]!.id;
39 let projekt: Project = {
40 ...roh,
41 program: { ...roh.program, method: 'manuell', manualCycleTime: 60 },
42 };
43 projekt = actions.setzeKoordinierung(projekt, {
44 hauptrichtung: k1,
45 ...(optionen.gegenrichtung === true ? { gegenrichtung: k2 } : {}),
46 progressionsgeschwindigkeit: 36,
47 platz: 0,
48 });
49 projekt = actions.koordinierungsknotenAnlegen(projekt, 'West');
50 projekt = actions.koordinierungsknotenAnlegen(projekt, 'Ost');
51 const [west, ost] = projekt.koordinierung!.nachbarn;
52 // 36 km/h sind genau 10 m/s: 200 m Abstand ergeben 20 s Reisezeit.
53 projekt = actions.koordinierungsknotenAendern(projekt, west!.id, {
54 abstand: 200,
55 freigabeBeginn: 20,
56 freigabeDauer: 25,
57 ...(optionen.gegenrichtung === true ? { gegenBeginn: 20, gegenDauer: 25 } : {}),
58 });
59 projekt = actions.koordinierungsknotenAendern(projekt, ost!.id, {
60 abstand: 300,
61 freigabeBeginn: 50,
62 freigabeDauer: 25,
63 ...(optionen.gegenrichtung === true ? { gegenBeginn: 50, gegenDauer: 25 } : {}),
64 });
65 return projekt;
66 }
67
68 function lage(projekt: Project) {
69 return koordinierungslage(projekt, buildSignalPlan(projekt));
70 }
71
72 describe('Koordinierung - die Kette entsteht aus Projekt und Plan', () => {
73 it('summiert die Entfernung über die Kette auf', () => {
74 // Die Wegachse des Zeit-Weg-Diagramms. Ohne sie bildete jede Ansicht die
75 // Summe selbst, und Diagramm und Tabelle liefen beim Runden auseinander.
76 const ergebnis = lage(mitKette()).ergebnis;
77 expect(ergebnis).not.toBeNull();
78 expect(ergebnis!.intersections.map((i) => i.cumulativeDistance)).toEqual([0, 200, 500]);
79 });
80
81 it('nimmt das Freigabefenster dieses Knotenpunkts aus dem Signalzeitenplan', () => {
82 /*
83 * DER UNTERSCHIED ZU DEN NACHBARN: Deren Fenster sind Eingaben, das eigene
84 * ist gerechnet. Es zieht damit bei jeder Aenderung der Phasen mit - eine
85 * eingetragene Kopie waere spaetestens beim naechsten Umlaufzeitwechsel
86 * falsch.
87 */
88 const projekt = mitKette();
89 const plan = buildSignalPlan(projekt);
90 const eigenes = plan.groups.find((g) => g.groupId === projekt.koordinierung!.hauptrichtung);
91 const glied = lage(projekt).kette.find((g) => g.eigener);
92 expect(glied?.freigabe).toEqual(eigenes?.greens[0]);
93 });
94
95 it('rechnet mit den Kennwerten dieses Projekts, nicht mit den Regelwerten', () => {
96 /*
97 * DIE UNVERHANDELBARE BEDINGUNG DES BEFUNDS. Ohne Eintrag gilt der
98 * Vorgabewert - aber der aus den Kennwerten DIESES Projekts. Wer ihn unter
99 * "Vorgaben" ändert, bekommt eine andere Reisezeit; sonst zeigte die
100 * Koordinierung Zahlen, die die Vorgabenansicht daneben widerlegt.
101 */
102 const ohneEintrag = actions.setzeKoordinierung(mitKette(), {
103 progressionsgeschwindigkeit: null,
104 });
105 expect(lage(ohneEintrag).ergebnis?.speed).toBe(RILSA_DEFAULTS.coordination.defaultSpeed);
106
107 const eigeneVorgabe: Project = {
108 ...ohneEintrag,
109 settings: {
110 ...ohneEintrag.settings,
111 rilsa: { ...ohneEintrag.settings.rilsa, coordinationDefaultSpeed: 54 },
112 },
113 };
114 const ergebnis = lage(eigeneVorgabe).ergebnis;
115 expect(ergebnis?.speed).toBe(54);
116 // 54 km/h sind 15 m/s: 200 m ergeben 13,3 s statt 20 s.
117 expect(ergebnis?.intersections[1]?.travelTime).toBeCloseTo(200 / (54 / 3.6), 1);
118 });
119
120 it('sagt, warum keine Rechnung möglich ist', () => {
121 const roh = basis();
122 expect(lage(roh).hindernis).toBe('keine-kette');
123
124 const ohneNachbar = actions.setzeKoordinierung(roh, {
125 hauptrichtung: roh.signalGroups[0]!.id,
126 });
127 expect(lage(ohneNachbar).hindernis).toBe('zu-wenige-knoten');
128
129 const ohneRichtung = actions.koordinierungsknotenAnlegen(
130 actions.setzeKoordinierung(roh, {}),
131 'West',
132 );
133 expect(lage(ohneRichtung).hindernis).toBe('keine-hauptrichtung');
134 });
135 });
136
137 describe('Koordinierung - die Aktionen', () => {
138 it('lassen ein Projekt ohne Kette unberührt', () => {
139 // Eine Aenderung an einer Kette, die es nicht gibt, darf keine anlegen -
140 // sonst entstuende eine Koordinierung als Nebenwirkung eines Klicks.
141 const roh = basis();
142 expect(actions.koordinierungsknotenAendern(roh, 'x', { abstand: 5 })).toBe(roh);
143 expect(actions.koordinierungsknotenEntfernen(roh, 'x')).toBe(roh);
144 expect(actions.entferneKoordinierung(roh).koordinierung).toBeNull();
145 });
146
147 it('ziehen den eigenen Platz mit, wenn ein Vorgänger fortfällt', () => {
148 /*
149 * Ohne das Nachziehen stuende dieser Knotenpunkt danach an einer anderen
150 * Stelle der Kette, ohne dass jemand ihn verschoben haette - und die
151 * Abstaende bezoegen sich auf einen anderen Nachbarn.
152 */
153 const projekt = actions.setzeKoordinierung(mitKette(), { platz: 2 });
154 const erster = projekt.koordinierung!.nachbarn[0]!;
155 const danach = actions.koordinierungsknotenEntfernen(projekt, erster.id);
156 expect(danach.koordinierung?.platz).toBe(1);
157 expect(lage(danach).kette.map((g) => g.eigener)).toEqual([false, true]);
158
159 // Faellt ein Knotenpunkt DAHINTER fort, bleibt der Platz stehen.
160 const vorn = actions.setzeKoordinierung(mitKette(), { platz: 0 });
161 const letzter = vorn.koordinierung!.nachbarn[1]!;
162 expect(actions.koordinierungsknotenEntfernen(vorn, letzter.id).koordinierung?.platz).toBe(0);
163 });
164
165 it('entfernt nur, was es gibt', () => {
166 const projekt = mitKette();
167 expect(actions.koordinierungsknotenEntfernen(projekt, 'gibt-es-nicht')).toBe(projekt);
168 });
169 });
170
171 describe('Koordinierung - die Lage des Bandes', () => {
172 it('nennt nicht nur die Breite, sondern auch den Beginn', () => {
173 /*
174 * OHNE DIE LAGE IST DAS BAND NICHT ZEICHENBAR, und darum geht es bei
175 * einem Zeit-Weg-Diagramm: Es zeigt, WANN ein Fahrzeug losfahren muss, um
176 * ohne Halt durchzukommen. Die Breite allein sagt nur, wie lang dieses
177 * Fenster ist, nicht wo es liegt.
178 *
179 * Nachgerechnet: Der eigene Knotenpunkt gibt frei, West ab 20 s bei 20 s
180 * Reisezeit, Ost ab 50 s bei 50 s Reisezeit - alle drei Fenster fallen
181 * damit auf den Umlaufbeginn zurück, und das Band beginnt bei 0 s.
182 */
183 const ergebnis = lage(mitKette()).ergebnis;
184 expect(ergebnis!.hinrichtung.beginn).toBe(0);
185 expect(ergebnis!.hinrichtung.breite).toBeGreaterThan(0);
186 // Die alten Felder sagen dasselbe wie das neue Band - zwei Angaben, eine
187 // Zahl.
188 expect(ergebnis!.bandwidth).toBe(ergebnis!.hinrichtung.breite);
189 expect(ergebnis!.bandwidthRatio).toBe(ergebnis!.hinrichtung.anteil);
190 });
191
192 it('verschiebt den Beginn mit dem Freigabefenster', () => {
193 // Die Gegenprobe: Verschieben sich alle Fenster um 10 s, verschiebt sich
194 // das Band mit. Bliebe der Beginn stehen, waere er keine Lage, sondern
195 // eine Zahl ohne Bezug.
196 const projekt = mitKette();
197 const verschoben = projekt.koordinierung!.nachbarn.reduce(
198 (p, n) =>
199 actions.koordinierungsknotenAendern(p, n.id, { freigabeBeginn: n.freigabeBeginn + 10 }),
200 projekt,
201 );
202 const vorher = lage(projekt).ergebnis!.hinrichtung;
203 const nachher = lage(verschoben).ergebnis!.hinrichtung;
204 expect(nachher.beginn).not.toBe(vorher.beginn);
205 });
206 });
207
208 describe('Koordinierung - in der Datei', () => {
209 it('verwirft eine halb erfasste Gegenrichtung und meldet es', () => {
210 /*
211 * Beide Felder stehen zusammen oder gar nicht. Ein Beginn ohne Dauer
212 * ergaebe ein Fenster ohne Laenge; die Rechnung liesse die Gegenrichtung
213 * dann stillschweigend fallen, und niemand saehe, warum.
214 */
215 const projekt = mitKette({ gegenrichtung: true });
216 const roh = JSON.parse(JSON.stringify(projekt)) as Record<string, unknown>;
217 const kette = roh['koordinierung'] as Record<string, unknown>;
218 const nachbarn = kette['nachbarn'] as Record<string, unknown>[];
219 delete nachbarn[0]!['gegenDauer'];
220
221 const geladen = loadProject(roh, STICHTAG);
222 const erster = geladen.project.koordinierung!.nachbarn[0]!;
223 expect(erster.gegenBeginn).toBeUndefined();
224 expect(erster.gegenDauer).toBeUndefined();
225 expect(geladen.issues.some((i) => i.message.includes('nur halb erfasst'))).toBe(true);
226 // Und ohne vollstaendige Angaben gibt es kein zweites Band.
227 expect(lage(geladen.project).ergebnis?.rueckrichtung).toBeNull();
228 });
229
230 it('trägt die Kette über Speichern und Laden', () => {
231 const projekt = mitKette({ gegenrichtung: true });
232 const geladen = loadProject(JSON.parse(JSON.stringify(projekt)) as unknown, STICHTAG);
233 expect(geladen.project.koordinierung?.nachbarn).toHaveLength(2);
234 expect(geladen.project.koordinierung?.progressionsgeschwindigkeit).toBe(36);
235 expect(lage(geladen.project).ergebnis?.hinrichtung).toEqual(
236 lage(projekt).ergebnis?.hinrichtung,
237 );
238 });
239 });
240
241 describe('Koordinierung - die zweite Fahrtrichtung', () => {
242 it('rechnet kein zweites Band, wo die Gegenrichtung fehlt', () => {
243 /*
244 * UND NIMMT AUCH KEINES AN. Eine Gegenrichtung, die stillschweigend die
245 * Fenster der Hinrichtung benutzt, waere eine erfundene Aussage ueber die
246 * halbe Koordinierung - und zwar ueber die Haelfte, die der eigentliche
247 * Gegenstand der Abstimmung ist.
248 */
249 const ohne = lage(mitKette());
250 expect(ohne.ergebnis?.rueckrichtung).toBeNull();
251 expect(ohne.gegenrichtungVollstaendig).toBe(false);
252 });
253
254 it('rechnet ein zweites Band, wo sie vollständig erfasst ist', () => {
255 const mit = lage(mitKette({ gegenrichtung: true }));
256 expect(mit.gegenrichtungVollstaendig).toBe(true);
257 expect(mit.ergebnis?.rueckrichtung).not.toBeNull();
258 expect(mit.ergebnis!.rueckrichtung!.breite).toBeGreaterThanOrEqual(0);
259 });
260
261 it('meldet es, wenn die Gegenrichtung leer ausgeht', () => {
262 /*
263 * Der Zielkonflikt in Zahlen: Dieselben Fenster, die in Hinrichtung ein
264 * breites Band ergeben, lassen in der Gegenrichtung keines übrig. Das ist
265 * eine Planungsentscheidung und keine Nebensache - der Bericht sagt es.
266 */
267 const projekt = mitKette({ gegenrichtung: true });
268 const eng = projekt.koordinierung!.nachbarn.reduce(
269 (p, n) => actions.koordinierungsknotenAendern(p, n.id, { gegenBeginn: 0, gegenDauer: 6 }),
270 projekt,
271 );
272 const ergebnis = lage(eng).ergebnis;
273 expect(ergebnis!.rueckrichtung!.breite).toBe(0);
274 expect(ergebnis!.notes.some((n) => n.code === 'keine-bandbreite-gegenrichtung')).toBe(true);
275 });
276
277 it('fährt die Gegenrichtung wirklich rückwärts durch die Kette', () => {
278 /*
279 * DER FALL, DER DEN UNTERSCHIED ZEIGT. Zwei Anlagen, 200 m auseinander,
280 * 10 m/s - also 20 s Reisezeit. Beide geben die Gegenrichtung 10 s lang
281 * frei, die eigene ab 0 s, die des Nachbarn ab 20 s.
282 *
283 * Rueckwaerts gerechnet (richtig): Ein Fahrzeug startet beim Nachbarn und
284 * erreicht diesen Knotenpunkt 20 s spaeter. Das Fenster hier liegt damit
285 * bei 40 s, das dort bei 20 s - kein gemeinsamer Bogen, Bandbreite 0.
286 *
287 * Vorwaerts gerechnet (falsch): Beide Fenster faenden sich bei 0 s, und
288 * die Rechnung meldete 10 s Bandbreite in einer Richtung, die keine hat.
289 */
290 const roh = basis();
291 let projekt: Project = {
292 ...roh,
293 program: { ...roh.program, method: 'manuell', manualCycleTime: 60 },
294 };
295 projekt = actions.setzeKoordinierung(projekt, {
296 hauptrichtung: roh.signalGroups[0]!.id,
297 gegenrichtung: roh.signalGroups[1]!.id,
298 progressionsgeschwindigkeit: 36,
299 platz: 0,
300 });
301 projekt = actions.koordinierungsknotenAnlegen(projekt, 'West');
302 const west = projekt.koordinierung!.nachbarn[0]!;
303 projekt = actions.koordinierungsknotenAendern(projekt, west.id, {
304 abstand: 200,
305 freigabeBeginn: 20,
306 freigabeDauer: 25,
307 gegenBeginn: 20,
308 gegenDauer: 10,
309 });
310 // Das eigene Fenster der Gegenrichtung kommt aus dem Plan; damit die
311 // Rechnung nachvollziehbar bleibt, wird es hier ueber den Nachbarn
312 // gespiegelt: Beide Fenster sind 10 s lang und liegen 20 s auseinander.
313 const ergebnis = lage(projekt).ergebnis;
314 expect(ergebnis!.rueckrichtung).not.toBeNull();
315 expect(ergebnis!.rueckrichtung!.breite).toBe(0);
316 });
317
318 it('setzt diesen Knotenpunkt an seinen Platz in der Kette', () => {
319 /*
320 * Der Platz entscheidet, WELCHER Nachbar der Vorgaenger ist - und damit,
321 * auf wen sich die Abstaende beziehen. Ein Knotenpunkt, der immer am
322 * Anfang stuende, verschoebe die ganze Kette um eine Stelle.
323 */
324 const vorn = mitKette();
325 expect(lage(vorn).kette.map((g) => g.eigener)).toEqual([true, false, false]);
326
327 const mitte = actions.setzeKoordinierung(vorn, { platz: 1 });
328 expect(lage(mitte).kette.map((g) => g.eigener)).toEqual([false, true, false]);
329 expect(lage(mitte).kette.map((g) => g.name)).toEqual([
330 'West',
331 lage(vorn).kette[0]!.name,
332 'Ost',
333 ]);
334
335 const hinten = actions.setzeKoordinierung(vorn, { platz: 2 });
336 expect(lage(hinten).kette.map((g) => g.eigener)).toEqual([false, false, true]);
337 });
338
339 it('lässt die Hinrichtung von der Gegenrichtung unberührt', () => {
340 // Die eine Richtung darf die andere nicht verändern: Beide Baender
341 // entstehen aus denselben Reisezeiten, aber aus getrennten Fenstern.
342 const ohne = lage(mitKette()).ergebnis!.hinrichtung;
343 const mit = lage(mitKette({ gegenrichtung: true })).ergebnis!.hinrichtung;
344 expect(mit).toEqual(ohne);
345 });
346 });