lsa-planer

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

/ tests domain freigabezeitrestVerteilung.test.ts

9,0 KB Rohdatei
tests/domain/freigabezeitrestVerteilung.test.ts — 224 Zeilen
1 import { describe, expect, it } from 'vitest';
2 import { distributeGreenTimes } from '@/domain/rilsa/greenSplit';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import {
5 createConflict,
6 createEmptyProject,
7 createPhase,
8 createSignalGroup,
9 } from '@/domain/model/factory';
10 import type { Project } from '@/domain/model/project';
11
12 /**
13 * Der Rest der Freigabezeitverteilung geht an alle Phasen mit Spielraum, nicht
14 * an eine.
15 *
16 * DER ANLASS IST EIN NACHGERECHNETER PLAN, keine Ueberlegung am Quelltext: ein
17 * angeordneter Plan, einstreifige Verkehrsfuehrung, zwei Richtungen,
18 * derselbe Raeumweg, dieselbe Zwischenzeit, dieselbe Hoechstfreigabezeit - und
19 * das Programm gab der einen Richtung 44 s Gruen und der anderen 40 s. An einer
20 * Baustellenampel ist das nicht erklaerbar: Es gibt nichts, was die beiden
21 * Richtungen unterscheidet. Die Frage "wie kann es hier ueberhaupt einen
22 * Unterschied geben" hat die Stelle gefunden.
23 *
24 * WAS DAHINTER STAND. Liegt jede Phase an ihrer Hoechstfreigabezeit und bleibt
25 * von der festen Umlaufzeit trotzdem etwas uebrig, muss dieser Rest vergeben
26 * werden - die Summe der Phasendauern hat die Umlaufzeit zu treffen. Bis 5.22.0
27 * ging er ganz an die Phase mit dem groessten Gewicht. Ohne Verkehrsstaerken
28 * sind alle Gewichte gleich; dann entschied der Index, und das war die erste
29 * Phase. Ein Gleichstand ist keine Auskunft darueber, wer die Zeit bekommen
30 * soll.
31 *
32 * WAS JETZT GILT: Der Rest geht nach Gewichten an die Phasen, die einen Bereich
33 * haben - und wo keine Verkehrsstaerken vorliegen, zu gleichen Teilen. Phasen,
34 * deren Dauer festliegt (`minGreen === maxGreen`), bleiben aussen vor, solange
35 * es andere gibt. Erst wenn jede Phase festliegt, werden alle ueberschritten.
36 *
37 * MUTATIONSNACHWEIS (10.09.2026, sechs Mutationen an
38 * `src/domain/rilsa/greenSplit.ts`): Jede wird gefunden. Der Rueckfall auf die
39 * erste Phase reisst sechs der neun Faelle nieder, ein unterbliebenes Vergeben
40 * des Rests ebenfalls sechs; die Verteilung ueber wirklich alle Phasen, das
41 * Uebergehen der Verkehrsstaerken und die beiden verstuemmelten Meldungen je
42 * einen bis zwei. Zwei Faelle bleiben bei jeder Mutation gruen, und das ist
43 * richtig so: Sie sind die Gegenproben mit einer Umlaufzeit von 96 s, bei der
44 * gar kein Rest entsteht.
45 */
46
47 const T = new Date('2026-01-22T00:00:00Z');
48
49 /** Zwei gleichgestellte Richtungen einer einstreifigen Fuehrung. */
50 function zweiRichtungen(umlauf: number, gewicht = 0) {
51 return distributeGreenTimes(
52 umlauf,
53 [
54 { id: 'a', weight: gewicht, minGreen: 10, maxGreen: 40 },
55 { id: 'b', weight: gewicht, minGreen: 10, maxGreen: 40 },
56 ],
57 [8, 8],
58 );
59 }
60
61 const gruen = (ergebnis: ReturnType<typeof distributeGreenTimes>): number[] =>
62 ergebnis.phases.map((p) => p.green);
63
64 const restmeldung = (ergebnis: ReturnType<typeof distributeGreenTimes>) =>
65 ergebnis.notes.find((n) => n.code === 'freigabezeit-rest');
66
67 describe('Der Rest der Freigabezeitverteilung', () => {
68 it('teilt sich gleichmaessig, wo nichts die Phasen unterscheidet', () => {
69 // 100 s minus 2 x 8 s Zwischenzeit sind 84 s; zwei Hoechstfreigabezeiten
70 // von 40 s nehmen davon 80 s auf. Die uebrigen 4 s gehoeren beiden.
71 expect(gruen(zweiRichtungen(100))).toEqual([42, 42]);
72 });
73
74 it('nennt die Meldung beide Phasen und die Kuerzung, die den Rest beseitigt', () => {
75 const meldung = restmeldung(zweiRichtungen(100));
76 expect(meldung, 'Restmeldung').toBeDefined();
77 expect(meldung!.severity).toBe('warnung');
78 expect(meldung!.message, 'die Zahl des Rests').toContain('4 s der Umlaufzeit bleiben übrig');
79 expect(meldung!.message, 'beide Empfaenger').toContain('Sie gehen an Phase 1, Phase 2');
80 /*
81 * Die Zahl, die weiterhilft: 100 - 4 = 96 s ist die groesste Umlaufzeit,
82 * die sich mit diesen Hoechstfreigabezeiten einhalten laesst. Bis 5.22.0
83 * stand dort nur "Verkürzen Sie die Umlaufzeit", ohne Betrag - der Planer
84 * musste ihn selbst ausrechnen.
85 */
86 expect(meldung!.message, 'der Weg heraus').toContain('Verkürzen Sie die Umlaufzeit um 4 s');
87 });
88
89 it('schweigt bei der Umlaufzeit, die aufgeht', () => {
90 const ergebnis = zweiRichtungen(96);
91 expect(gruen(ergebnis)).toEqual([40, 40]);
92 expect(restmeldung(ergebnis), 'ohne Rest keine Meldung').toBeUndefined();
93 });
94
95 it('laesst eine von Hand vorgegebene Freigabezeit unangetastet', () => {
96 /*
97 * GEGENPROBE ZUR GLEICHVERTEILUNG, und sie hat den ersten Anlauf dieser
98 * Aenderung umgeworfen: Wer eine feste Freigabezeit eintraegt, hat
99 * entschieden. Der Rest gehoert dann ganz an die Phase mit Bereich, auch
100 * wenn das unsymmetrisch aussieht - hier unterscheidet die Vorgabe die
101 * beiden Phasen sehr wohl.
102 */
103 const ergebnis = distributeGreenTimes(
104 80,
105 [
106 { id: 'fest', weight: 0.4, minGreen: 6, maxGreen: 6 },
107 { id: 'frei', weight: 0.6, minGreen: 10, maxGreen: 60 },
108 ],
109 [5, 5],
110 );
111 expect(gruen(ergebnis)).toEqual([6, 64]);
112 expect(restmeldung(ergebnis)!.message, 'nur die Phase mit Bereich').toContain(
113 'Sie gehen an Phase 2 –',
114 );
115 expect(restmeldung(ergebnis)!.message, 'die Zusage an die Vorgabe').toContain(
116 'Phasen, deren Freigabezeit festliegt, bleiben unangetastet.',
117 );
118 });
119
120 it('ueberschreitet alle Vorgaben, wo keine Phase mehr Spielraum hat - und sagt es', () => {
121 const ergebnis = distributeGreenTimes(
122 80,
123 [
124 { id: 'a', weight: 0, minGreen: 20, maxGreen: 20 },
125 { id: 'b', weight: 0, minGreen: 20, maxGreen: 20 },
126 ],
127 [5, 5],
128 );
129 // 70 s verfuegbar, 40 s vorgegeben: Die 30 s Rest lassen sich nirgends
130 // sonst unterbringen.
131 expect(gruen(ergebnis)).toEqual([35, 35]);
132 const meldung = restmeldung(ergebnis);
133 expect(meldung!.message, 'der Vorbehalt faellt fort').not.toContain('bleiben unangetastet');
134 expect(meldung!.message, 'und wird durch das Gegenteil ersetzt').toContain(
135 'Keine Phase hat dabei Spielraum; auch eine fest vorgegebene Freigabezeit wird überschritten.',
136 );
137 });
138
139 it('folgt der Verkehrsstaerke, wo eine vorliegt', () => {
140 /*
141 * Gleichverteilung ist nicht das Ziel, sondern die Folge fehlender
142 * Angaben. Wo Verkehrsstaerken erfasst sind, ist eine unterschiedliche
143 * Freigabezeit begruendet - und dann soll sie auch unterschiedlich sein.
144 */
145 const ergebnis = zweiRichtungen(100, 0);
146 const gewichtet = distributeGreenTimes(
147 100,
148 [
149 { id: 'a', weight: 0.6, minGreen: 10, maxGreen: 40 },
150 { id: 'b', weight: 0.2, minGreen: 10, maxGreen: 40 },
151 ],
152 [8, 8],
153 );
154 expect(gruen(ergebnis), 'ohne Verkehrsstaerken gleich').toEqual([42, 42]);
155 expect(gruen(gewichtet), 'mit Verkehrsstaerken nach Bedarf').toEqual([43, 41]);
156 });
157
158 it('trifft die Summe in jedem dieser Faelle die verfuegbare Zeit', () => {
159 // Der Grund, aus dem der Rest ueberhaupt vergeben werden muss. Ein
160 // Signalzeitenplan, dessen Phasendauern die Umlaufzeit verfehlen, ist
161 // nicht schaltbar.
162 for (const umlauf of [96, 97, 98, 99, 100, 101, 110]) {
163 const ergebnis = zweiRichtungen(umlauf);
164 const summe = gruen(ergebnis).reduce((a, b) => a + b, 0);
165 expect(summe, `Umlaufzeit ${umlauf} s`).toBe(ergebnis.availableGreen);
166 }
167 });
168 });
169
170 /**
171 * Und derselbe Sachverhalt am ganzen Plan statt an der Verteilung allein: Der
172 * nachgerechnete Plan B, aufgebaut aus den Angaben der Anordnung.
173 */
174 describe('Die einstreifige Fuehrung von Plan B, nachgebaut', () => {
175 function planB(umlauf: number): Project {
176 const basis = createEmptyProject('Plan B', T, 'einstreifig');
177 const richtung = (name: string, index: number) => ({
178 ...createSignalGroup({ name, mode: 'kfz' as const, vZul: 50, index }),
179 minGreenOverride: 10,
180 maxGreenOverride: 40,
181 });
182 const k1 = richtung('K1', 0);
183 const k2 = richtung('K2', 1);
184 const p1 = createPhase('Seite A', [k1.id]);
185 const p2 = createPhase('Seite B', [k2.id]);
186 const beziehung = (von: string, nach: string) => ({
187 ...createConflict(von, nach),
188 clearingDistance: 40,
189 enteringDistance: 0,
190 });
191 return {
192 ...basis,
193 signalGroups: [k1, k2],
194 phases: [p1, p2],
195 conflicts: [beziehung(k1.id, k2.id), beziehung(k2.id, k1.id)],
196 program: {
197 ...basis.program,
198 phaseOrder: [p1.id, p2.id],
199 manualCycleTime: umlauf,
200 method: 'manuell' as const,
201 },
202 };
203 }
204
205 it('gibt beiden Richtungen dieselbe Freigabezeit', () => {
206 const plan = buildSignalPlan(planB(100));
207 expect(plan.cycleTime).toBe(100);
208 expect(
209 plan.transitions.map((t) => t.duration),
210 'Zwischenzeit je Richtung',
211 ).toEqual([8, 8]);
212 // Bis 5.22.0 standen hier 44 und 40 s.
213 expect(plan.phases.map((p) => p.duration)).toEqual([42, 42]);
214 });
215
216 it('braucht 96 s, damit die Hoechstfreigabezeit von 40 s eingehalten ist', () => {
217 const plan = buildSignalPlan(planB(96));
218 expect(plan.phases.map((p) => p.duration)).toEqual([40, 40]);
219 expect(
220 plan.notes.map((n) => n.code),
221 'bei 96 s bleibt nichts uebrig',
222 ).not.toContain('freigabezeit-rest');
223 });
224 });