lsa-planer

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

/ src domain plan aspects.ts

10,6 KB Rohdatei
src/domain/plan/aspects.ts — 321 Zeilen
1 import type { Seconds } from '../units';
2 import { roundTo } from '../units';
3 import type { SignalAspect } from '../rilsa/types';
4 import type { GreenInterval, PlannedSignalGroup } from './signalPlan';
5
6 /**
7 * Ableitung der Signalbilder aus den Freigabezeitfenstern.
8 *
9 * Diese Datei ist die einzige Quelle dafuer, welches Signalbild eine
10 * Signalgruppe zu einem Zeitpunkt zeigt. Darstellung des Signalzeitenplans,
11 * Simulation und Ausdruck greifen alle darauf zu. Im Altbestand zeichnete die
12 * Plandarstellung die Rot-Gelb-Phase VOR den Umlaufbeginn (also ausserhalb des
13 * Diagramms, wo sie unsichtbar blieb), waehrend die Simulation sie ans Ende der
14 * Rotzeit setzte - die beiden Ansichten widersprachen sich.
15 *
16 * Zeitliche Anordnung eines Freigabezeitfensters nach RiLSA:
17 *
18 * ... Rot ... | Rot-Gelb | ####### Freigabezeit ####### | Gelb | ... Rot ...
19 * ^ start - tRotGelb ^ start + Dauer
20 *
21 * Die Zwischenzeit reicht vom Ende der Freigabezeit der raeumenden Gruppe bis
22 * zum Beginn der Freigabezeit der einfahrenden Gruppe; Gelb der einen und
23 * Rot-Gelb der anderen Gruppe liegen darin.
24 */
25
26 export interface AspectSegment {
27 readonly aspect: SignalAspect;
28 readonly start: Seconds;
29 readonly duration: Seconds;
30 }
31
32 /** Positiver Rest, auch fuer negative Eingaben. */
33 export function modulo(value: number, m: number): number {
34 if (m <= 0) return 0;
35 return ((value % m) + m) % m;
36 }
37
38 /**
39 * Zerlegt den Umlauf einer Signalgruppe in Abschnitte gleichen Signalbilds.
40 * Die Abschnitte sind nach Beginn sortiert, ueberschneiden sich nicht und
41 * ergeben in Summe genau die Umlaufzeit.
42 *
43 * VORRANG BEI UEBERSCHNEIDUNG: Freigabe vor Gelb vor Rot-Gelb.
44 *
45 * Die Freigabezeitfenster sind die Aussage des Plans; Gelb und Rot-Gelb werden
46 * daraus abgeleitet. Liegen zwei Fenster derselben Signalgruppe naeher
47 * beieinander, als Gelb und Rot-Gelb lang sind, wird deshalb das Gelb bzw. das
48 * Rot-Gelb gekuerzt - nie die Freigabe. Vorher wurden die Marken allein nach
49 * Beginn geordnet und am fortlaufenden Cursor abgeschnitten; das kuerzte die
50 * FREIGABE des zweiten Fensters um die Ueberschneidung (Befund F33). Die
51 * Signalbilder wichen damit von denselben Fenstern ab, aus denen sie stammen:
52 * `aspectTotals(...).gruen` war kleiner als `totalGreen` des Plans, waehrend
53 * `isGreenAt` - und mit ihm Simulation und Gleichzeitigkeitspruefung - die
54 * volle Freigabe meldete.
55 */
56 export function aspectSegments(
57 group: Pick<PlannedSignalGroup, 'greens' | 'times'>,
58 cycleTime: Seconds,
59 ): AspectSegment[] {
60 if (cycleTime <= 0) return [];
61
62 // Dauerfreigabe.
63 if (group.greens.length === 1 && (group.greens[0]?.duration ?? 0) >= cycleTime) {
64 return [{ aspect: 'gruen', start: 0, duration: cycleTime }];
65 }
66 if (group.greens.length === 0) {
67 return [{ aspect: 'rot', start: 0, duration: cycleTime }];
68 }
69
70 // Jedes Fenster liefert Rot-Gelb, Gruen und Gelb; die Luecken sind Rot.
71 const gruen: Mark[] = [];
72 const gelb: Mark[] = [];
73 const rotgelb: Mark[] = [];
74 for (const green of group.greens) {
75 if (group.times.redYellow > 0) {
76 rotgelb.push({
77 start: modulo(green.start - group.times.redYellow, cycleTime),
78 duration: group.times.redYellow,
79 aspect: 'rotgelb',
80 });
81 }
82 gruen.push({
83 start: modulo(green.start, cycleTime),
84 duration: green.duration,
85 aspect: 'gruen',
86 });
87 if (group.times.yellow > 0) {
88 gelb.push({
89 start: modulo(green.start + green.duration, cycleTime),
90 duration: group.times.yellow,
91 aspect: 'gelb',
92 });
93 }
94 }
95
96 // In der Reihenfolge des Vorrangs belegen; was belegt ist, wird dem
97 // nachrangigen Signalbild weggeschnitten.
98 const belegt: AspectSegment[] = [];
99 for (const stufe of [gruen, gelb, rotgelb]) {
100 for (const segment of splitAtCycleEnd(stufe, cycleTime)) {
101 belegt.push(...ohneUeberschneidung(segment, belegt));
102 }
103 }
104
105 return fillWithRed(
106 belegt.sort((a, b) => a.start - b.start),
107 cycleTime,
108 );
109 }
110
111 interface Mark {
112 readonly start: Seconds;
113 readonly duration: Seconds;
114 readonly aspect: SignalAspect;
115 }
116
117 /** Was von `segment` uebrig bleibt, wenn die bereits belegten Zeiten entfallen. */
118 function ohneUeberschneidung(
119 segment: AspectSegment,
120 belegt: readonly AspectSegment[],
121 ): AspectSegment[] {
122 let stuecke: { start: Seconds; end: Seconds }[] = [
123 { start: segment.start, end: segment.start + segment.duration },
124 ];
125
126 for (const anderer of belegt) {
127 const von = anderer.start;
128 const bis = anderer.start + anderer.duration;
129 const rest: { start: Seconds; end: Seconds }[] = [];
130 for (const stueck of stuecke) {
131 if (bis <= stueck.start || von >= stueck.end) {
132 rest.push(stueck);
133 continue;
134 }
135 if (von > stueck.start) rest.push({ start: stueck.start, end: von });
136 if (bis < stueck.end) rest.push({ start: bis, end: stueck.end });
137 }
138 stuecke = rest;
139 }
140
141 return stuecke
142 .filter((stueck) => stueck.end - stueck.start > 1e-6)
143 .map((stueck) => ({
144 aspect: segment.aspect,
145 start: stueck.start,
146 duration: stueck.end - stueck.start,
147 }));
148 }
149
150 /** Zerteilt Abschnitte, die ueber das Umlaufende hinausreichen. */
151 function splitAtCycleEnd(marks: readonly Mark[], cycleTime: Seconds): AspectSegment[] {
152 const result: AspectSegment[] = [];
153 for (const mark of marks) {
154 if (mark.duration <= 0) continue;
155 const end = mark.start + mark.duration;
156 if (end <= cycleTime) {
157 result.push({ aspect: mark.aspect, start: mark.start, duration: mark.duration });
158 } else {
159 result.push({ aspect: mark.aspect, start: mark.start, duration: cycleTime - mark.start });
160 result.push({ aspect: mark.aspect, start: 0, duration: end - cycleTime });
161 }
162 }
163 return result.sort((a, b) => a.start - b.start);
164 }
165
166 /**
167 * Fuellt die Luecken zwischen den Abschnitten mit Rot auf.
168 *
169 * Die Abschnitte kommen ueberschneidungsfrei herein (der Vorrang ist in
170 * `aspectSegments` bereits aufgeloest); das Abschneiden am Cursor ist nur noch
171 * die Absicherung der Zusage "ueberschneiden sich nicht".
172 */
173 function fillWithRed(segments: readonly AspectSegment[], cycleTime: Seconds): AspectSegment[] {
174 const result: AspectSegment[] = [];
175 let cursor = 0;
176
177 for (const segment of segments) {
178 if (segment.duration <= 0) continue;
179 if (segment.start > cursor + 1e-6) {
180 result.push({
181 aspect: 'rot',
182 start: roundTo(cursor, 3),
183 duration: roundTo(segment.start - cursor, 3),
184 });
185 }
186 const start = Math.max(cursor, segment.start);
187 const end = Math.min(cycleTime, segment.start + segment.duration);
188 if (end > start) {
189 result.push({
190 aspect: segment.aspect,
191 start: roundTo(start, 3),
192 duration: roundTo(end - start, 3),
193 });
194 cursor = end;
195 }
196 }
197
198 if (cursor < cycleTime - 1e-6) {
199 result.push({
200 aspect: 'rot',
201 start: roundTo(cursor, 3),
202 duration: roundTo(cycleTime - cursor, 3),
203 });
204 }
205
206 return result;
207 }
208
209 /** Signalbild zu einem Zeitpunkt innerhalb des Umlaufs. */
210 export function aspectAt(
211 group: Pick<PlannedSignalGroup, 'greens' | 'times'>,
212 time: Seconds,
213 cycleTime: Seconds,
214 ): SignalAspect {
215 if (cycleTime <= 0) return 'dunkel';
216 const t = modulo(time, cycleTime);
217 for (const segment of aspectSegments(group, cycleTime)) {
218 if (t >= segment.start && t < segment.start + segment.duration) return segment.aspect;
219 }
220 return 'rot';
221 }
222
223 /** Zeigt die Signalgruppe zu diesem Zeitpunkt Freigabe? */
224 export function isGreenAt(
225 group: Pick<PlannedSignalGroup, 'greens' | 'times'>,
226 time: Seconds,
227 cycleTime: Seconds,
228 ): boolean {
229 if (cycleTime <= 0) return false;
230 const t = modulo(time, cycleTime);
231 for (const green of group.greens) {
232 const start = modulo(green.start, cycleTime);
233 const end = start + green.duration;
234 if (end <= cycleTime) {
235 if (t >= start && t < end) return true;
236 } else if (t >= start || t < end - cycleTime) {
237 return true;
238 }
239 }
240 return false;
241 }
242
243 /**
244 * Farbwerte der Signalbilder fuer Darstellung und Ausdruck.
245 *
246 * BEWUSST UNVERAENDERT (Befund M5): Die Nachbarpaare bleiben untereinander
247 * unter den 3:1 der WCAG 2.1, Ziffer 1.4.11
248 * - Rot/Gruen 1,40:1, Rot-Gelb/Gruen 1,29:1, Gruen/Gelb 1,86:1 -, und Gelb
249 * kommt gegen die weisse Zeichenflaeche nur auf 1,84:1. Es sind die
250 * Signalfarben, an denen ein Pruefer den Plan gegen die RiLSA liest; sie sind
251 * nicht frei waehlbar, und die Ausnahme "wesentlich" der WCAG greift dafuer.
252 *
253 * Der Kontrast wird deshalb NEBEN der Farbe hergestellt: durch das Kuerzel im
254 * Balken (ASPECT_KUERZEL, gleich darunter), durch die Trennlinie an jeder
255 * Abschnittsgrenze (ABSCHNITTSGRENZE in src/render/signalPlanDrawing.ts) und
256 * durch die Schriftfarbe darauf (TEXT_AUF_SIGNALBILD ebenda). Wer hier Werte
257 * aendert, muss alle drei neu nachrechnen.
258 */
259 export const ASPECT_COLORS: Readonly<Record<SignalAspect, string>> = {
260 rot: '#d13b2e',
261 rotgelb: '#e08b1f',
262 gruen: '#2e9e4f',
263 gelb: '#e8b923',
264 dunkel: '#555a63',
265 'gelb-blinkend': '#e8b923',
266 };
267
268 /**
269 * Kuerzel der Signalbilder fuer die Zeichnung.
270 *
271 * DER GRUND: Im Zeitdiagramm trug das Signalbild seine Aussage bisher
272 * ausschliesslich ueber die Farbe. Der einzige Text im Balken war die Dauer -
273 * also in jedem Abschnitt derselbe Zahlentyp. Wer Rot und Gruen nicht
274 * auseinanderhalten kann, konnte den Plan nicht pruefen; im Schwarzweissdruck
275 * konnte es niemand.
276 *
277 * Die Kuerzel sind die in der Signalplanung ueblichen und stehen zusaetzlich zur
278 * Dauer im Balken.
279 */
280 export const ASPECT_KUERZEL: Readonly<Record<SignalAspect, string>> = {
281 rot: 'R',
282 rotgelb: 'RG',
283 gruen: 'F',
284 gelb: 'GE',
285 dunkel: 'D',
286 'gelb-blinkend': 'GB',
287 };
288
289 /** Beschriftungen der Signalbilder. */
290 export const ASPECT_LABELS: Readonly<Record<SignalAspect, string>> = {
291 rot: 'Rot',
292 rotgelb: 'Rot-Gelb',
293 gruen: 'Freigabe',
294 gelb: 'Gelb',
295 dunkel: 'Dunkel',
296 'gelb-blinkend': 'Gelb blinkend',
297 };
298
299 /** Gesamtdauer eines Signalbilds im Umlauf - fuer die Plantabelle. */
300 export function aspectTotals(
301 group: Pick<PlannedSignalGroup, 'greens' | 'times'>,
302 cycleTime: Seconds,
303 ): Readonly<Record<SignalAspect, Seconds>> {
304 const totals: Record<SignalAspect, Seconds> = {
305 rot: 0,
306 rotgelb: 0,
307 gruen: 0,
308 gelb: 0,
309 dunkel: 0,
310 'gelb-blinkend': 0,
311 };
312 for (const segment of aspectSegments(group, cycleTime)) {
313 totals[segment.aspect] = roundTo(totals[segment.aspect] + segment.duration, 3);
314 }
315 return totals;
316 }
317
318 /** Freigabezeitfenster, absteigend nach Dauer - fuer Kurzangaben im Bericht. */
319 export function primaryGreen(greens: readonly GreenInterval[]): GreenInterval | undefined {
320 return [...greens].sort((a, b) => b.duration - a.duration)[0];
321 }