lsa-planer

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

/ src domain plan koordinierung.ts

12,1 KB Rohdatei
src/domain/plan/koordinierung.ts — 294 Zeilen
1 import { computeCoordination, type CoordinatedIntersection } from '../rilsa/coordination';
2 import type { Band, CoordinationResult } from '../rilsa/coordination';
3 import type { Project } from '../model/project';
4 import type { GreenInterval, SignalPlan } from './signalPlan';
5 import type { Meters } from '../units';
6
7 /**
8 * Die Koordinierung dieses Knotenpunkts in seinem Strassenzug.
9 *
10 * SIE VERBINDET ZWEI WELTEN, und deshalb steht sie hier und nicht in
11 * `rilsa/coordination.ts`: Dort steht die reine Rechnung ueber eine Kette von
12 * Knotenpunkten, hier die Frage, WOHER diese Kette kommt. Die Nachbarn stehen
13 * im Projekt, das eigene Freigabefenster im Signalzeitenplan - und das ist der
14 * entscheidende Unterschied: Es wird nicht eingetragen, sondern gerechnet und
15 * zieht bei jeder Aenderung der Phasen mit.
16 *
17 * DIE VERSATZZEIT VERSCHIEBT DEN EIGENEN PLAN NACH WIE VOR NICHT. Was hier
18 * entsteht, ist eine Empfehlung fuer die Nachbaranlagen; der Signalzeitenplan
19 * dieses Knotenpunkts bleibt unberuehrt. Das ist keine Luecke, sondern die
20 * Abgrenzung: Ein Versatz gilt zwischen Anlagen, nicht innerhalb einer.
21 */
22
23 /** Warum keine Koordinierung gerechnet werden kann. */
24 export type Koordinierungshindernis =
25 | 'keine-kette'
26 | 'zu-wenige-knoten'
27 | 'keine-hauptrichtung'
28 | 'hauptrichtung-ohne-freigabe'
29 | 'keine-umlaufzeit';
30
31 export interface Kettenglied {
32 readonly id: string;
33 readonly name: string;
34 /** Ist das dieser Knotenpunkt - der, den das Projekt beschreibt? */
35 readonly eigener: boolean;
36 readonly abstand: Meters;
37 readonly freigabe: GreenInterval;
38 readonly gegenrichtung: GreenInterval | null;
39 }
40
41 export interface Koordinierungslage {
42 readonly hindernis: Koordinierungshindernis | null;
43 readonly kette: readonly Kettenglied[];
44 readonly ergebnis: CoordinationResult | null;
45 /**
46 * Fuehrt jeder Knotenpunkt der Kette eine Gegenrichtung?
47 *
48 * Nur dann rechnet die Koordinierung ein zweites Band. Die Ansicht sagt es
49 * mit dieser Angabe, statt das fehlende Band unerklaert zu lassen.
50 */
51 readonly gegenrichtungVollstaendig: boolean;
52 /** Ist die Hauptrichtung an diesem Knotenpunkt in mehreren Fenstern frei? */
53 readonly mehrereFreigabefenster: boolean;
54 /**
55 * Und dieselbe Frage fuer die Gegenrichtung (Fassung 5.43.0).
56 *
57 * ZWEI ANGABEN UND NICHT EINE, weil die Ansicht sagt, WELCHE Signalgruppe
58 * mehrere Fenster hat. Bis hierher gab es nur die erste, und die Auswahl an
59 * der Gegenrichtung traf das Programm stillschweigend: `fensterMitBand` waehlt
60 * fuer jede Richtung an IHREM eigenen Band, und ein Knotenpunkt, dessen
61 * Gegenrichtung in zwei Phasen frei ist, ist derselbe Fall wie der, den die
62 * Ansicht fuer die Hauptrichtung meldet.
63 */
64 readonly mehrereFreigabefensterGegenrichtung: boolean;
65 }
66
67 /**
68 * Die laengsten Freigabefenster einer Signalgruppe dieses Plans, nach ihrem
69 * Beginn im Umlauf geordnet - meist genau eines.
70 *
71 * DAS LAENGSTE Fenster, nicht das erste. Eine Signalgruppe kann in getrennten
72 * Phasen freigegeben sein; fuer eine Gruene Welle traegt das laengste Fenster,
73 * denn durch das kuerzere passt der Pulk nicht. Die Ansicht meldet diesen Fall
74 * gesondert - eine Auswahl, die niemand getroffen hat, gehoert ausgesprochen.
75 *
76 * MEHRERE, WEIL EIN GLEICHSTAND MOEGLICH IST: Sind zwei Fenster gleich lang,
77 * traegt die Laenge die Entscheidung nicht mehr, und wer dann das erste aus
78 * `greens` nimmt, laesst die Phasenreihenfolge entscheiden. Welches von beiden
79 * gilt, beantwortet `fensterMitBand` am Band - siehe dort.
80 */
81 function laengsteFenster(plan: SignalPlan, groupId: string | null): GreenInterval[] {
82 if (groupId === null) return [];
83 const gruppe = plan.groups.find((g) => g.groupId === groupId);
84 if (gruppe === undefined) return [];
85 const laenge = gruppe.greens.reduce(
86 (groesste, g) => Math.max(groesste, g.duration),
87 Number.NEGATIVE_INFINITY,
88 );
89 return gruppe.greens.filter((g) => g.duration === laenge).sort((a, b) => a.start - b.start);
90 }
91
92 /**
93 * Welches von mehreren gleich langen Fenstern gilt - entschieden am BAND.
94 *
95 * DER GLEICHSTAND DARF NICHT DIE FELDREIHENFOLGE ENTSCHEIDEN (Grundsatz;
96 * fuenfte Stelle dieser Gestalt, Fassung 5.43.0). In `laengsteFenster`
97 * stand `g.duration > laengstes.duration`, und bei zwei gleich langen Fenstern
98 * gewann damit das, das in `greens` zuerst steht - also die Phasenreihenfolge.
99 * Ein Knotenpunkt mit vier Phasen, dessen Hauptrichtung in der ersten und der
100 * dritten freigegeben ist, hat genau diesen Fall.
101 *
102 * ES GIBT EINE FEINERE FACHLICHE AUSKUNFT, und der Grundsatz verlangt, zuerst nach
103 * ihr zu suchen: WELCHES DER BEIDEN FENSTER UEBERHAUPT EIN BAND ERGIBT.
104 * Gemessen an genau dem Knotenpunkt des Falls E in
105 * `tests/domain/gleichstandOhneReihenfolge.test.ts` (vier Phasen, tU 60 s,
106 * Fenster {0 s, 9 s} und {30 s, 9 s}, ein Nachbar in 200 m mit Freigabe ab
107 * 20 s fuer 12 s, 36 km/h): Das erste ergibt 9 s Bandbreite, Anteil 0,15,
108 * Guete "gering"; das zweite 0 s und den FEHLER "keine durchgehende
109 * Bandbreite" - fuer denselben Plan.
110 *
111 * DIE ERSTE FASSUNG DIESER STELLE BEHAUPTETE DAS GEGENTEIL, und zwar in beide
112 * Richtungen vertauscht: Zwei gleich lange Fenster seien fuer das Band
113 * gleichwertig, ein verschobenes Fenster verschiebe alle Versatzempfehlungen
114 * gleichmaessig mit. Beides ist am Code widerlegt. Die Versatzzeiten entstehen
115 * in `coordination.ts` allein aus Abstand und Progressionsgeschwindigkeit und
116 * beruehrt die Wahl gar nicht (`intersections[].offset`; in der Messung oben
117 * zeichengleich). Die Bandbreite dagegen ist die Schnittmenge ALLER Fenster,
118 * und die der Nachbarn sind Eingabedaten, die sich nicht mitverschieben - sie
119 * haengt an der Wahl.
120 *
121 * ERST BEI GLEICHER BANDBREITE eine stabile Ordnung: `laengsteFenster` liefert
122 * die Kandidaten nach ihrem Beginn geordnet, und der Vergleich `>` laesst dann
123 * das im Umlauf fruehere gewinnen. DIESE zweite Stufe bedeutet nichts - sie
124 * verhindert nur, dass die Feldreihenfolge entscheidet.
125 *
126 * Ohne weitere Kandidaten wird `band` NICHT gerufen: Die Koordinierung soll
127 * nicht mehrfach gerechnet werden, wo es nichts zu waehlen gibt.
128 */
129 function fensterMitBand(
130 erstes: GreenInterval,
131 weitere: readonly GreenInterval[],
132 band: (fenster: GreenInterval) => Band | null,
133 ): GreenInterval {
134 if (weitere.length === 0) return erstes;
135 let gewaehlt = erstes;
136 let breite = band(erstes)?.breite ?? 0;
137 for (const kandidat of weitere) {
138 const kandidatenbreite = band(kandidat)?.breite ?? 0;
139 if (kandidatenbreite > breite) {
140 gewaehlt = kandidat;
141 breite = kandidatenbreite;
142 }
143 }
144 return gewaehlt;
145 }
146
147 /** Baut die Kette aus Projekt und Plan und rechnet sie durch. */
148 export function koordinierungslage(project: Project, plan: SignalPlan): Koordinierungslage {
149 const kette = project.koordinierung;
150 if (kette === null) return leer('keine-kette');
151
152 if (kette.hauptrichtung === null) return leer('keine-hauptrichtung');
153
154 const hauptfenster = laengsteFenster(plan, kette.hauptrichtung);
155 const erstesHaupt = hauptfenster[0] ?? null;
156 if (erstesHaupt === null || erstesHaupt.duration <= 0) {
157 return leer('hauptrichtung-ohne-freigabe');
158 }
159
160 const gegenfenster = laengsteFenster(plan, kette.gegenrichtung);
161 const erstesGegen = gegenfenster[0] ?? null;
162 const platz = Math.min(Math.max(Math.trunc(kette.platz) || 0, 0), kette.nachbarn.length);
163
164 const baueKette = (haupt: GreenInterval, gegen: GreenInterval | null): Kettenglied[] => {
165 const glieder: Kettenglied[] = kette.nachbarn.map((n) => ({
166 id: n.id,
167 name: n.name,
168 eigener: false,
169 abstand: n.abstand,
170 freigabe: { start: n.freigabeBeginn, duration: n.freigabeDauer },
171 gegenrichtung:
172 n.gegenBeginn === undefined || n.gegenDauer === undefined
173 ? null
174 : { start: n.gegenBeginn, duration: n.gegenDauer },
175 }));
176 glieder.splice(platz, 0, {
177 id: project.id,
178 name: project.intersection.name === '' ? project.meta.name : project.intersection.name,
179 eigener: true,
180 abstand: kette.eigenerAbstand,
181 freigabe: haupt,
182 gegenrichtung: gegen,
183 });
184 return glieder;
185 };
186
187 const ersteKette = baueKette(erstesHaupt, erstesGegen);
188 if (ersteKette.length < 2) return { ...leer('zu-wenige-knoten'), kette: ersteKette };
189 if (plan.cycleTime <= 0) return { ...leer('keine-umlaufzeit'), kette: ersteKette };
190
191 /*
192 * MIT DEN KENNWERTEN DIESES PLANS, nicht mit den Regelwerten: Wer unter
193 * "Vorgaben" eine andere Progressionsgeschwindigkeit oder einen anderen
194 * zulaessigen Bereich gesetzt hat, bekommt seine Zahlen - dieselben, die die
195 * Vorgabenansicht und der Ausdruck ausweisen. Dazu die zulaessige
196 * Hoechstgeschwindigkeit des Knotenpunkts, aus der die relative Regel der
197 * RiLSA (90 bis 100 %) gebildet wird.
198 */
199 const rechne = (haupt: GreenInterval, gegen: GreenInterval | null): CoordinationResult =>
200 computeCoordination(
201 knotenAus(baueKette(haupt, gegen)),
202 plan.cycleTime,
203 kette.progressionsgeschwindigkeit ?? undefined,
204 plan.defaults,
205 project.intersection.vZul,
206 );
207
208 /*
209 * JEDE RICHTUNG AN IHREM EIGENEN BAND (siehe `fensterMitBand`). Die beiden
210 * Waehlbaren sind unabhaengig: Die Gegenrichtung faellt in `coordination.ts`
211 * aus `returnGreen*` und der Gesamtreisezeit, das Fenster der Hinrichtung
212 * geht in sie nicht ein. Gewaehlt wird deshalb nacheinander und mit dem
213 * bereits gewaehlten Gegenstueck - so haengt keine der beiden Antworten an
214 * der Reihenfolge, in der hier gesucht wird.
215 */
216 const haupt = fensterMitBand(
217 erstesHaupt,
218 hauptfenster.slice(1),
219 (kandidat) => rechne(kandidat, erstesGegen).hinrichtung,
220 );
221 const gegen =
222 erstesGegen === null
223 ? null
224 : fensterMitBand(
225 erstesGegen,
226 gegenfenster.slice(1),
227 (kandidat) => rechne(haupt, kandidat).rueckrichtung,
228 );
229
230 const glieder = baueKette(haupt, gegen);
231
232 return {
233 hindernis: null,
234 kette: glieder,
235 ergebnis: rechne(haupt, gegen),
236 gegenrichtungVollstaendig: glieder.every((g) => g.gegenrichtung !== null),
237 mehrereFreigabefenster: mehrfach(plan, kette.hauptrichtung),
238 mehrereFreigabefensterGegenrichtung:
239 kette.gegenrichtung !== null && mehrfach(plan, kette.gegenrichtung),
240 };
241 }
242
243 /** Die Kette in die Eingabe der reinen Rechnung uebersetzen. */
244 function knotenAus(glieder: readonly Kettenglied[]): CoordinatedIntersection[] {
245 return glieder.map((g, index) => ({
246 id: g.id,
247 name: g.name,
248 // Der erste Knotenpunkt hat keinen Vorgaenger; sein Abstand ist 0.
249 distanceToPrevious: index === 0 ? 0 : g.abstand,
250 greenStart: g.freigabe.start,
251 greenDuration: g.freigabe.duration,
252 ...(g.gegenrichtung === null
253 ? {}
254 : {
255 returnGreenStart: g.gegenrichtung.start,
256 returnGreenDuration: g.gegenrichtung.duration,
257 }),
258 }));
259 }
260
261 function mehrfach(plan: SignalPlan, groupId: string): boolean {
262 const gruppe = plan.groups.find((g) => g.groupId === groupId);
263 return gruppe !== undefined && gruppe.greens.length > 1;
264 }
265
266 function leer(hindernis: Koordinierungshindernis): Koordinierungslage {
267 return {
268 hindernis,
269 kette: [],
270 ergebnis: null,
271 gegenrichtungVollstaendig: false,
272 mehrereFreigabefenster: false,
273 mehrereFreigabefensterGegenrichtung: false,
274 };
275 }
276
277 /** Klartext zu einem Hindernis - eine Stelle, damit Ansicht und Ausdruck dasselbe sagen. */
278 export const HINDERNIS_TEXT: Readonly<Record<Koordinierungshindernis, string>> = {
279 'keine-kette':
280 'Für diesen Knotenpunkt ist keine Koordinierung erfasst. Tragen Sie die Nachbaranlagen mit ' +
281 'ihrem Abstand und ihren Freigabezeiten ein.',
282 'zu-wenige-knoten':
283 'Eine Koordinierung setzt mindestens zwei Anlagen voraus. Tragen Sie wenigstens eine ' +
284 'Nachbaranlage ein.',
285 'keine-hauptrichtung':
286 'Es ist nicht festgelegt, welche Signalgruppe dieses Knotenpunkts die koordinierte ' +
287 'Hauptrichtung führt.',
288 'hauptrichtung-ohne-freigabe':
289 'Die Signalgruppe der Hauptrichtung bekommt in diesem Signalzeitenplan keine Freigabezeit. ' +
290 'Ohne Freigabefenster gibt es kein Band.',
291 'keine-umlaufzeit':
292 'Dieser Signalzeitenplan hat keine Umlaufzeit. Eine Grüne Welle setzt eine gemeinsame ' +
293 'Umlaufzeit aller beteiligten Anlagen voraus.',
294 };