lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ src domain plan koordinierung.ts
| 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 | }; |