lsa-planer

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

/ tests domain phaseneinteilung.test.ts

14,9 KB Rohdatei
tests/domain/phaseneinteilung.test.ts — 403 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import {
3 leiteAb,
4 uebernimmSignalgruppen,
5 uebernimmWege,
6 wegSchluessel,
7 } from '@/domain/geometrie/ableitung';
8 import { REGELBREITE } from '@/domain/geometrie/vermessung';
9 import { createEmptyProject, createSignalGroup } from '@/domain/model/factory';
10 import {
11 MAX_PHASENMENGEN,
12 MAX_VORSCHLAEGE,
13 maximaleMengen,
14 sucheEinteilungen,
15 vertraeglichkeitsbelege,
16 } from '@/domain/plan/phaseneinteilung';
17 import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
18 import type { Project } from '@/domain/model/project';
19
20 /**
21 * Fassung 5.36.0 - zulaessige Phaseneinteilungen.
22 *
23 * DIE BEDINGUNG, UNTER DER DIESER PUNKT UEBERHAUPT GEBAUT WERDEN DURFTE:
24 * "Ohne positiven Beleg der Vertraeglichkeit jedes Paares darf kein Vorschlag
25 * erscheinen. Das ist keine Feinheit." Der erste Abschnitt dieser Datei
26 * bewacht genau das - und zwar in beide Richtungen: kein Vorschlag ohne Beleg,
27 * und Vorschlaege, sobald er da ist.
28 *
29 * Der Grund: Eine Reihung nach kleinster Umlaufzeit bevorzugt wenige Phasen,
30 * also viele gleichzeitig freigegebene Gruppen. Im Datenbestand sieht
31 * "geprueft vertraeglich" aber genauso aus wie "noch nicht betrachtet", und
32 * mit jedem entfallenden Phasenuebergang faellt eine Zwischenzeit weg.
33 */
34
35 const STICHTAG = new Date('2026-01-01T00:00:00Z');
36
37 function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie {
38 return {
39 id,
40 name,
41 mode: 'kfz',
42 movement: 'geradeaus',
43 breiteMeter: REGELBREITE.kfz,
44 punkte,
45 haltlinieId: null,
46 startT: 0.5,
47 signalGroupId: null,
48 };
49 }
50
51 /**
52 * Drei Stroeme: K1 und K3 laufen parallel, K2 kreuzt beide.
53 *
54 * Damit gibt es genau eine sinnvolle Einteilung - {K1, K3} und {K2} -, und
55 * jede Aussage dieser Datei laesst sich von Hand nachvollziehen.
56 */
57 function lageplan(): Lageplan {
58 return {
59 arbeitsbereiche: [],
60 signalgeber: [],
61 haltlinien: [],
62 bild: {
63 datenUrl: 'data:image/png;base64,AAAA',
64 breite: 400,
65 hoehe: 400,
66 herkunft: 'Prüfstück',
67 geladenAm: STICHTAG.toISOString(),
68 },
69 // 100 Bildpunkte sind 50 m.
70 kalibrierung: {
71 von: { x: 0, y: 0 },
72 bis: { x: 100, y: 0 },
73 laengeMeter: 50,
74 herkunft: 'gemessen',
75 },
76 linien: [
77 linie('l-w', 'K1', [
78 { x: 0, y: 100 },
79 { x: 200, y: 100 },
80 ]),
81 linie('l-s', 'K2', [
82 { x: 100, y: 0 },
83 { x: 100, y: 200 },
84 ]),
85 linie('l-p', 'K3', [
86 { x: 0, y: 160 },
87 { x: 200, y: 160 },
88 ]),
89 ],
90 };
91 }
92
93 /**
94 * Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt.
95 *
96 * Der Weg ist derselbe, den die Oberflaeche geht: Signalgruppen aus den
97 * Fahrlinien uebernehmen, danach die Wege. Ein von Hand gebautes Projekt
98 * haette den Beleg nicht - und genau darum geht es hier.
99 */
100 function belegtesProjekt(): Project {
101 const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() };
102 const alleLinien = leer.lageplan.linien.map((l) => l.id);
103 const gruppen = uebernimmSignalgruppen(leer, leer.lageplan, alleLinien);
104 const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan };
105 const ableitung = leiteAb(mitPlan, mitPlan.lageplan);
106 const wege = uebernimmWege(
107 mitPlan,
108 mitPlan.lageplan,
109 ableitung,
110 ableitung.wege.map((w) => wegSchluessel(w)),
111 STICHTAG,
112 );
113 return wege.project;
114 }
115
116 describe('Phaseneinteilung - ohne Beleg kein Vorschlag', () => {
117 it('macht keinen einzigen Vorschlag, solange ein Paar unbelegt ist', () => {
118 /*
119 * DER FALL, UM DEN ES GEHT. Zwei Signalgruppen, kein Lageplan, kein
120 * erfasster Konflikt: Das Programm HÄLT sie für gleichzeitig freigebbar –
121 * der Signalzeitenplan tut es jedenfalls. Ein Vorschlag würde daraus eine
122 * Empfehlung machen, und niemand hätte je geprüft, ob sich die beiden
123 * Ströme vertragen.
124 */
125 const roh = createEmptyProject('ohne Plan', STICHTAG);
126 const ohnePlan: Project = {
127 ...roh,
128 signalGroups: [{ ...leereGruppe('sg-a', 'K1') }, { ...leereGruppe('sg-b', 'K2') }],
129 };
130 const suche = sucheEinteilungen(ohnePlan);
131 expect(suche.hindernis).toBe('ohne-beleg');
132 expect(suche.vorschlaege).toEqual([]);
133 expect(suche.beleg.ohneBeleg).toHaveLength(1);
134 expect(suche.beleg.ohneBeleg[0]?.grund).toContain('nicht belegt');
135 });
136
137 it('nennt die unbelegten Paare beim Namen', () => {
138 // Ein leeres Ergebnis ohne Grund waere das Schlimmste: Der Bearbeiter
139 // hielte es fuer "es gibt keine Einteilung".
140 const projekt = belegtesProjekt();
141 const zusatz: Project = {
142 ...projekt,
143 signalGroups: [...projekt.signalGroups, leereGruppe('sg-neu', 'K9')],
144 };
145 const suche = sucheEinteilungen(zusatz);
146 expect(suche.hindernis).toBe('ohne-beleg');
147 const namen = suche.beleg.ohneBeleg.map((p) => `${p.aName}/${p.bName}`);
148 expect(namen.every((n) => n.includes('K9'))).toBe(true);
149 expect(namen.length).toBe(3);
150 });
151
152 it('nimmt den Beleg aus dem Lageplan und schlägt dann vor', () => {
153 const suche = sucheEinteilungen(belegtesProjekt());
154 expect(suche.hindernis).toBeNull();
155 expect(suche.beleg.vollstaendig).toBe(true);
156 expect(suche.vorschlaege.length).toBeGreaterThan(0);
157 // Der Beleg sagt, woher er kommt - und nennt die geprüften Strompaare.
158 const belegt = suche.beleg.paare.filter((p) => p.art === 'belegt');
159 expect(belegt.length).toBeGreaterThan(0);
160 expect(belegt[0]?.grund).toContain('Im Lageplan geprüft');
161 });
162
163 it('belegt kein Paar, dessen Fahrlinien sich kreuzen - auch ohne erfassten Konflikt', () => {
164 /*
165 * DER GEFAEHRLICHSTE FALL. Wer eine Konfliktbeziehung von Hand aufhebt,
166 * ohne die Zeichnung zu aendern, hinterlaesst ein Paar OHNE erfassten
167 * Konflikt, dessen Fahrlinien sich kreuzen. Wuerde die Zeichnung hier
168 * ignoriert, gaelte das Paar als "belegt vertraeglich" - und der Vorschlag
169 * gaebe zwei kreuzenden Stroemen gleichzeitig Gruen.
170 */
171 const projekt = belegtesProjekt();
172 const ohneKonflikte: Project = { ...projekt, conflicts: [] };
173 const stand = vertraeglichkeitsbelege(ohneKonflikte);
174
175 expect(stand.paare.some((p) => p.art === 'feindlich')).toBe(false);
176 const kreuzend = stand.paare.filter((p) => p.art === 'ohne-beleg');
177 expect(kreuzend.length, 'die kreuzenden Paare dürfen nicht als belegt gelten').toBe(2);
178 expect(kreuzend[0]?.grund).toContain('uneindeutig');
179 expect(sucheEinteilungen(ohneKonflikte).hindernis).toBe('ohne-beleg');
180 });
181
182 it('führt eine erfasste feindliche Beziehung als Aussage, nicht als Lücke', () => {
183 const suche = sucheEinteilungen(belegtesProjekt());
184 const feindlich = suche.beleg.paare.filter((p) => p.art === 'feindlich');
185 expect(feindlich.length).toBeGreaterThan(0);
186 expect(feindlich[0]?.grund).toContain('erfasst');
187 });
188 });
189
190 describe('Phaseneinteilung - was vorgeschlagen wird', () => {
191 it('fasst nur paarweise verträgliche Gruppen in eine Phase', () => {
192 /*
193 * DIE ZWEITE SICHERHEITSZUSAGE. Eine Phase ist eine Menge gleichzeitig
194 * freigegebener Gruppen; steht darin ein feindliches Paar, bekommen zwei
195 * kreuzende Ströme zugleich Grün.
196 */
197 const projekt = belegtesProjekt();
198 const feindlich = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|')));
199 for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) {
200 for (const phase of vorschlag.phasen) {
201 for (let i = 0; i < phase.length; i += 1) {
202 for (let j = i + 1; j < phase.length; j += 1) {
203 expect(feindlich.has([phase[i]!, phase[j]!].sort().join('|'))).toBe(false);
204 }
205 }
206 }
207 }
208 });
209
210 it('fasst jede Phase so weit wie möglich zusammen', () => {
211 /*
212 * Eine Phase, der sich eine weitere vertraegliche Gruppe hinzufuegen
213 * liesse, verschenkt Freigabezeit - sie waere nie die Antwort auf "welche
214 * Einteilung ergibt die kuerzeste Umlaufzeit". Solche Vorschlaege
215 * blaehten die Liste auf und stuenden mit schlechteren Zahlen darin.
216 */
217 const projekt = belegtesProjekt();
218 const belegt = new Set(
219 vertraeglichkeitsbelege(projekt)
220 .paare.filter((p) => p.art === 'belegt')
221 .map((p) => [p.aId, p.bId].sort().join('|')),
222 );
223 const vertraeglich = (a: string, b: string): boolean => belegt.has([a, b].sort().join('|'));
224
225 for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) {
226 for (const phase of vorschlag.phasen) {
227 const erweiterbar = projekt.signalGroups
228 .map((g) => g.id)
229 .filter((id) => !phase.includes(id) && phase.every((p) => vertraeglich(p, id)));
230 expect(erweiterbar, `Phase ${phase.join('+')} ließe sich erweitern`).toEqual([]);
231 }
232 }
233 });
234
235 it('gibt jeder Signalgruppe wenigstens eine Phase', () => {
236 // Eine Gruppe ohne Phase bekommt nie Freigabe - ein Vorschlag, der sie
237 // auslaesst, waere kein Signalzeitenplan.
238 const projekt = belegtesProjekt();
239 const alle = new Set(projekt.signalGroups.map((g) => g.id));
240 for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) {
241 const enthalten = new Set(vorschlag.phasen.flat());
242 expect(enthalten).toEqual(alle);
243 }
244 });
245
246 it('reiht nach Umlaufzeit', () => {
247 /*
248 * MIT VIER GRUPPEN, damit es ueberhaupt etwas zu reihen gibt: Bei drei
249 * Stroemen bleibt eine einzige Einteilung uebrig, und eine Liste mit einem
250 * Eintrag ist in jeder Reihenfolge sortiert - ein Fall, der das nicht
251 * beachtet, bewacht nichts.
252 */
253 const vorschlaege = sucheEinteilungen(vierStroeme()).vorschlaege;
254 expect(vorschlaege.length, 'zum Reihen braucht es mehr als einen Vorschlag').toBeGreaterThan(1);
255 const zeiten = vorschlaege.map((v) => v.umlaufzeit);
256 expect(new Set(zeiten).size, 'und mehr als eine Umlaufzeit').toBeGreaterThan(1);
257 expect([...zeiten].sort((a, b) => a - b)).toEqual(zeiten);
258 for (const zeit of zeiten) expect(zeit).toBeGreaterThan(0);
259 });
260
261 it('erkennt die heutige Einteilung wieder', () => {
262 /*
263 * Ohne diesen Vermerk sucht der Bearbeiter in der Liste nach dem, was er
264 * schon hat - und findet es nicht, weil die Phasen anders heissen.
265 */
266 const projekt = belegtesProjekt();
267 const suche = sucheEinteilungen(projekt);
268 const erste = suche.vorschlaege[0];
269 expect(erste).toBeDefined();
270
271 const uebernommen: Project = {
272 ...projekt,
273 phases: erste!.phasen.map((gruppen, index) => ({
274 id: `p-${String(index)}`,
275 name: `Phase ${String(index + 1)}`,
276 signalGroupIds: [...gruppen],
277 manualGreen: null,
278 })),
279 };
280 const danach = sucheEinteilungen(uebernommen);
281 expect(danach.vorschlaege.some((v) => v.heutige)).toBe(true);
282 });
283
284 it('bleibt unter der Höchstzahl der Vorschläge', () => {
285 expect(sucheEinteilungen(belegtesProjekt()).vorschlaege.length).toBeLessThanOrEqual(
286 MAX_VORSCHLAEGE,
287 );
288 });
289
290 it('sagt bei einer einzigen Gruppe, dass es nichts einzuteilen gibt', () => {
291 const roh = createEmptyProject('eine', STICHTAG);
292 const eine: Project = { ...roh, signalGroups: [leereGruppe('sg-a', 'K1')] };
293 expect(sucheEinteilungen(eine).hindernis).toBe('zu-wenige-gruppen');
294 });
295 });
296
297 /** Eine Signalgruppe ohne Bezug zum Lageplan - der unbelegte Fall. */
298 function leereGruppe(id: string, name: string): Project['signalGroups'][number] {
299 return {
300 ...createSignalGroup({ mode: 'kfz', name }),
301 id,
302 };
303 }
304
305 /**
306 * Vier Stroeme, deren Vertraeglichkeit einen Viererkreis bildet: A vertraegt
307 * sich mit B und C, D mit B und C - aber A nicht mit D und B nicht mit C.
308 *
309 * Erst diese Form ergibt ZWEI verschiedene Zweiphaseneinteilungen ({A,B} +
310 * {C,D} und {A,C} + {B,D}) und dazu dreiphasige. Ein Pruefstueck mit nur einer
311 * moeglichen Einteilung koennte eine Reihung nicht pruefen: Eine Liste mit
312 * einem Eintrag ist in jeder Reihenfolge sortiert.
313 */
314 function vierStroeme(): Project {
315 const erweitert: Lageplan = {
316 ...lageplan(),
317 linien: [
318 linie('a', 'A', [
319 { x: 0, y: 100 },
320 { x: 200, y: 100 },
321 ]),
322 linie('b', 'B', [
323 { x: 0, y: 160 },
324 { x: 200, y: 160 },
325 ]),
326 // Kreuzt B, nicht A.
327 linie('c', 'C', [
328 { x: 40, y: 140 },
329 { x: 40, y: 260 },
330 ]),
331 // Kreuzt A, nicht B.
332 linie('d', 'D', [
333 { x: 100, y: 0 },
334 { x: 100, y: 130 },
335 ]),
336 ],
337 };
338 const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: erweitert };
339 const gruppen = uebernimmSignalgruppen(
340 leer,
341 leer.lageplan,
342 leer.lageplan.linien.map((l) => l.id),
343 );
344 const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan };
345 const ableitung = leiteAb(mitPlan, mitPlan.lageplan);
346 return uebernimmWege(
347 mitPlan,
348 mitPlan.lageplan,
349 ableitung,
350 ableitung.wege.map((w) => wegSchluessel(w)),
351 STICHTAG,
352 ).project;
353 }
354
355 describe('Phaseneinteilung - gezaehlt wird, bevor aufgezaehlt wird', () => {
356 it('bricht ab, wo zu viele Phasenmengen möglich sind', () => {
357 /*
358 * DAS ZWEITE RISIKO DES BEFUNDS: Der Suchraum haengt an der Zahl der
359 * ERFASSTEN Konflikte, nicht an der Zahl der Gruppen - ausgerechnet das
360 * schlecht gepflegte Projekt haette den groessten Suchraum und bekaeme die
361 * meisten Vorschlaege.
362 *
363 * Gebaut ist hier der schlimmste Fall in klein: sieben Dreiergruppen, die
364 * untereinander alle vertraeglich sind, innerhalb der Gruppe aber nicht.
365 * Das ergibt 3^7 = 2187 maximale Mengen - weit ueber der Schranke.
366 */
367 const ids = Array.from({ length: 21 }, (_, i) => `g${String(i)}`);
368 const drittel = (id: string): number => Math.floor(Number(id.slice(1)) / 3);
369 const ergebnis = maximaleMengen(ids, (a, b) => drittel(a) !== drittel(b));
370 expect(ergebnis.abgebrochen).toBe(true);
371 expect(ergebnis.mengen.length).toBeLessThanOrEqual(MAX_PHASENMENGEN);
372 });
373
374 it('zählt bei überschaubaren Verhältnissen zu Ende', () => {
375 const ergebnis = maximaleMengen(
376 ['a', 'b', 'c'],
377 (x, y) => !(x === 'a' && y === 'b') && !(x === 'b' && y === 'a'),
378 );
379 expect(ergebnis.abgebrochen).toBe(false);
380 expect(ergebnis.mengen.map((m) => [...m].sort().join('+')).sort()).toEqual(['a+c', 'b+c']);
381 });
382 });
383
384 describe('Phaseneinteilung - der Belegstand selbst', () => {
385 it('unterscheidet drei Zustände und wirft keinen in den anderen', () => {
386 const projekt = belegtesProjekt();
387 const arten = new Set(vertraeglichkeitsbelege(projekt).paare.map((p) => p.art));
388 expect(arten.has('feindlich')).toBe(true);
389 expect(arten.has('belegt')).toBe(true);
390 expect(arten.has('ohne-beleg')).toBe(false);
391 });
392
393 it('lässt ohne auswertbaren Lageplan nichts als belegt gelten', () => {
394 const projekt = belegtesProjekt();
395 const ohneMassstab: Project = {
396 ...projekt,
397 lageplan: { ...projekt.lageplan, kalibrierung: null },
398 };
399 const stand = vertraeglichkeitsbelege(ohneMassstab);
400 expect(stand.paare.some((p) => p.art === 'belegt')).toBe(false);
401 expect(stand.vollstaendig).toBe(false);
402 });
403 });