import { computeCoordination, type CoordinatedIntersection } from '../rilsa/coordination'; import type { Band, CoordinationResult } from '../rilsa/coordination'; import type { Project } from '../model/project'; import type { GreenInterval, SignalPlan } from './signalPlan'; import type { Meters } from '../units'; /** * Die Koordinierung dieses Knotenpunkts in seinem Strassenzug. * * SIE VERBINDET ZWEI WELTEN, und deshalb steht sie hier und nicht in * `rilsa/coordination.ts`: Dort steht die reine Rechnung ueber eine Kette von * Knotenpunkten, hier die Frage, WOHER diese Kette kommt. Die Nachbarn stehen * im Projekt, das eigene Freigabefenster im Signalzeitenplan - und das ist der * entscheidende Unterschied: Es wird nicht eingetragen, sondern gerechnet und * zieht bei jeder Aenderung der Phasen mit. * * DIE VERSATZZEIT VERSCHIEBT DEN EIGENEN PLAN NACH WIE VOR NICHT. Was hier * entsteht, ist eine Empfehlung fuer die Nachbaranlagen; der Signalzeitenplan * dieses Knotenpunkts bleibt unberuehrt. Das ist keine Luecke, sondern die * Abgrenzung: Ein Versatz gilt zwischen Anlagen, nicht innerhalb einer. */ /** Warum keine Koordinierung gerechnet werden kann. */ export type Koordinierungshindernis = | 'keine-kette' | 'zu-wenige-knoten' | 'keine-hauptrichtung' | 'hauptrichtung-ohne-freigabe' | 'keine-umlaufzeit'; export interface Kettenglied { readonly id: string; readonly name: string; /** Ist das dieser Knotenpunkt - der, den das Projekt beschreibt? */ readonly eigener: boolean; readonly abstand: Meters; readonly freigabe: GreenInterval; readonly gegenrichtung: GreenInterval | null; } export interface Koordinierungslage { readonly hindernis: Koordinierungshindernis | null; readonly kette: readonly Kettenglied[]; readonly ergebnis: CoordinationResult | null; /** * Fuehrt jeder Knotenpunkt der Kette eine Gegenrichtung? * * Nur dann rechnet die Koordinierung ein zweites Band. Die Ansicht sagt es * mit dieser Angabe, statt das fehlende Band unerklaert zu lassen. */ readonly gegenrichtungVollstaendig: boolean; /** Ist die Hauptrichtung an diesem Knotenpunkt in mehreren Fenstern frei? */ readonly mehrereFreigabefenster: boolean; /** * Und dieselbe Frage fuer die Gegenrichtung (Fassung 5.43.0). * * ZWEI ANGABEN UND NICHT EINE, weil die Ansicht sagt, WELCHE Signalgruppe * mehrere Fenster hat. Bis hierher gab es nur die erste, und die Auswahl an * der Gegenrichtung traf das Programm stillschweigend: `fensterMitBand` waehlt * fuer jede Richtung an IHREM eigenen Band, und ein Knotenpunkt, dessen * Gegenrichtung in zwei Phasen frei ist, ist derselbe Fall wie der, den die * Ansicht fuer die Hauptrichtung meldet. */ readonly mehrereFreigabefensterGegenrichtung: boolean; } /** * Die laengsten Freigabefenster einer Signalgruppe dieses Plans, nach ihrem * Beginn im Umlauf geordnet - meist genau eines. * * DAS LAENGSTE Fenster, nicht das erste. Eine Signalgruppe kann in getrennten * Phasen freigegeben sein; fuer eine Gruene Welle traegt das laengste Fenster, * denn durch das kuerzere passt der Pulk nicht. Die Ansicht meldet diesen Fall * gesondert - eine Auswahl, die niemand getroffen hat, gehoert ausgesprochen. * * MEHRERE, WEIL EIN GLEICHSTAND MOEGLICH IST: Sind zwei Fenster gleich lang, * traegt die Laenge die Entscheidung nicht mehr, und wer dann das erste aus * `greens` nimmt, laesst die Phasenreihenfolge entscheiden. Welches von beiden * gilt, beantwortet `fensterMitBand` am Band - siehe dort. */ function laengsteFenster(plan: SignalPlan, groupId: string | null): GreenInterval[] { if (groupId === null) return []; const gruppe = plan.groups.find((g) => g.groupId === groupId); if (gruppe === undefined) return []; const laenge = gruppe.greens.reduce( (groesste, g) => Math.max(groesste, g.duration), Number.NEGATIVE_INFINITY, ); return gruppe.greens.filter((g) => g.duration === laenge).sort((a, b) => a.start - b.start); } /** * Welches von mehreren gleich langen Fenstern gilt - entschieden am BAND. * * DER GLEICHSTAND DARF NICHT DIE FELDREIHENFOLGE ENTSCHEIDEN (Grundsatz; * fuenfte Stelle dieser Gestalt, Fassung 5.43.0). In `laengsteFenster` * stand `g.duration > laengstes.duration`, und bei zwei gleich langen Fenstern * gewann damit das, das in `greens` zuerst steht - also die Phasenreihenfolge. * Ein Knotenpunkt mit vier Phasen, dessen Hauptrichtung in der ersten und der * dritten freigegeben ist, hat genau diesen Fall. * * ES GIBT EINE FEINERE FACHLICHE AUSKUNFT, und der Grundsatz verlangt, zuerst nach * ihr zu suchen: WELCHES DER BEIDEN FENSTER UEBERHAUPT EIN BAND ERGIBT. * Gemessen an genau dem Knotenpunkt des Falls E in * `tests/domain/gleichstandOhneReihenfolge.test.ts` (vier Phasen, tU 60 s, * Fenster {0 s, 9 s} und {30 s, 9 s}, ein Nachbar in 200 m mit Freigabe ab * 20 s fuer 12 s, 36 km/h): Das erste ergibt 9 s Bandbreite, Anteil 0,15, * Guete "gering"; das zweite 0 s und den FEHLER "keine durchgehende * Bandbreite" - fuer denselben Plan. * * DIE ERSTE FASSUNG DIESER STELLE BEHAUPTETE DAS GEGENTEIL, und zwar in beide * Richtungen vertauscht: Zwei gleich lange Fenster seien fuer das Band * gleichwertig, ein verschobenes Fenster verschiebe alle Versatzempfehlungen * gleichmaessig mit. Beides ist am Code widerlegt. Die Versatzzeiten entstehen * in `coordination.ts` allein aus Abstand und Progressionsgeschwindigkeit und * beruehrt die Wahl gar nicht (`intersections[].offset`; in der Messung oben * zeichengleich). Die Bandbreite dagegen ist die Schnittmenge ALLER Fenster, * und die der Nachbarn sind Eingabedaten, die sich nicht mitverschieben - sie * haengt an der Wahl. * * ERST BEI GLEICHER BANDBREITE eine stabile Ordnung: `laengsteFenster` liefert * die Kandidaten nach ihrem Beginn geordnet, und der Vergleich `>` laesst dann * das im Umlauf fruehere gewinnen. DIESE zweite Stufe bedeutet nichts - sie * verhindert nur, dass die Feldreihenfolge entscheidet. * * Ohne weitere Kandidaten wird `band` NICHT gerufen: Die Koordinierung soll * nicht mehrfach gerechnet werden, wo es nichts zu waehlen gibt. */ function fensterMitBand( erstes: GreenInterval, weitere: readonly GreenInterval[], band: (fenster: GreenInterval) => Band | null, ): GreenInterval { if (weitere.length === 0) return erstes; let gewaehlt = erstes; let breite = band(erstes)?.breite ?? 0; for (const kandidat of weitere) { const kandidatenbreite = band(kandidat)?.breite ?? 0; if (kandidatenbreite > breite) { gewaehlt = kandidat; breite = kandidatenbreite; } } return gewaehlt; } /** Baut die Kette aus Projekt und Plan und rechnet sie durch. */ export function koordinierungslage(project: Project, plan: SignalPlan): Koordinierungslage { const kette = project.koordinierung; if (kette === null) return leer('keine-kette'); if (kette.hauptrichtung === null) return leer('keine-hauptrichtung'); const hauptfenster = laengsteFenster(plan, kette.hauptrichtung); const erstesHaupt = hauptfenster[0] ?? null; if (erstesHaupt === null || erstesHaupt.duration <= 0) { return leer('hauptrichtung-ohne-freigabe'); } const gegenfenster = laengsteFenster(plan, kette.gegenrichtung); const erstesGegen = gegenfenster[0] ?? null; const platz = Math.min(Math.max(Math.trunc(kette.platz) || 0, 0), kette.nachbarn.length); const baueKette = (haupt: GreenInterval, gegen: GreenInterval | null): Kettenglied[] => { const glieder: Kettenglied[] = kette.nachbarn.map((n) => ({ id: n.id, name: n.name, eigener: false, abstand: n.abstand, freigabe: { start: n.freigabeBeginn, duration: n.freigabeDauer }, gegenrichtung: n.gegenBeginn === undefined || n.gegenDauer === undefined ? null : { start: n.gegenBeginn, duration: n.gegenDauer }, })); glieder.splice(platz, 0, { id: project.id, name: project.intersection.name === '' ? project.meta.name : project.intersection.name, eigener: true, abstand: kette.eigenerAbstand, freigabe: haupt, gegenrichtung: gegen, }); return glieder; }; const ersteKette = baueKette(erstesHaupt, erstesGegen); if (ersteKette.length < 2) return { ...leer('zu-wenige-knoten'), kette: ersteKette }; if (plan.cycleTime <= 0) return { ...leer('keine-umlaufzeit'), kette: ersteKette }; /* * MIT DEN KENNWERTEN DIESES PLANS, nicht mit den Regelwerten: Wer unter * "Vorgaben" eine andere Progressionsgeschwindigkeit oder einen anderen * zulaessigen Bereich gesetzt hat, bekommt seine Zahlen - dieselben, die die * Vorgabenansicht und der Ausdruck ausweisen. Dazu die zulaessige * Hoechstgeschwindigkeit des Knotenpunkts, aus der die relative Regel der * RiLSA (90 bis 100 %) gebildet wird. */ const rechne = (haupt: GreenInterval, gegen: GreenInterval | null): CoordinationResult => computeCoordination( knotenAus(baueKette(haupt, gegen)), plan.cycleTime, kette.progressionsgeschwindigkeit ?? undefined, plan.defaults, project.intersection.vZul, ); /* * JEDE RICHTUNG AN IHREM EIGENEN BAND (siehe `fensterMitBand`). Die beiden * Waehlbaren sind unabhaengig: Die Gegenrichtung faellt in `coordination.ts` * aus `returnGreen*` und der Gesamtreisezeit, das Fenster der Hinrichtung * geht in sie nicht ein. Gewaehlt wird deshalb nacheinander und mit dem * bereits gewaehlten Gegenstueck - so haengt keine der beiden Antworten an * der Reihenfolge, in der hier gesucht wird. */ const haupt = fensterMitBand( erstesHaupt, hauptfenster.slice(1), (kandidat) => rechne(kandidat, erstesGegen).hinrichtung, ); const gegen = erstesGegen === null ? null : fensterMitBand( erstesGegen, gegenfenster.slice(1), (kandidat) => rechne(haupt, kandidat).rueckrichtung, ); const glieder = baueKette(haupt, gegen); return { hindernis: null, kette: glieder, ergebnis: rechne(haupt, gegen), gegenrichtungVollstaendig: glieder.every((g) => g.gegenrichtung !== null), mehrereFreigabefenster: mehrfach(plan, kette.hauptrichtung), mehrereFreigabefensterGegenrichtung: kette.gegenrichtung !== null && mehrfach(plan, kette.gegenrichtung), }; } /** Die Kette in die Eingabe der reinen Rechnung uebersetzen. */ function knotenAus(glieder: readonly Kettenglied[]): CoordinatedIntersection[] { return glieder.map((g, index) => ({ id: g.id, name: g.name, // Der erste Knotenpunkt hat keinen Vorgaenger; sein Abstand ist 0. distanceToPrevious: index === 0 ? 0 : g.abstand, greenStart: g.freigabe.start, greenDuration: g.freigabe.duration, ...(g.gegenrichtung === null ? {} : { returnGreenStart: g.gegenrichtung.start, returnGreenDuration: g.gegenrichtung.duration, }), })); } function mehrfach(plan: SignalPlan, groupId: string): boolean { const gruppe = plan.groups.find((g) => g.groupId === groupId); return gruppe !== undefined && gruppe.greens.length > 1; } function leer(hindernis: Koordinierungshindernis): Koordinierungslage { return { hindernis, kette: [], ergebnis: null, gegenrichtungVollstaendig: false, mehrereFreigabefenster: false, mehrereFreigabefensterGegenrichtung: false, }; } /** Klartext zu einem Hindernis - eine Stelle, damit Ansicht und Ausdruck dasselbe sagen. */ export const HINDERNIS_TEXT: Readonly> = { 'keine-kette': 'Für diesen Knotenpunkt ist keine Koordinierung erfasst. Tragen Sie die Nachbaranlagen mit ' + 'ihrem Abstand und ihren Freigabezeiten ein.', 'zu-wenige-knoten': 'Eine Koordinierung setzt mindestens zwei Anlagen voraus. Tragen Sie wenigstens eine ' + 'Nachbaranlage ein.', 'keine-hauptrichtung': 'Es ist nicht festgelegt, welche Signalgruppe dieses Knotenpunkts die koordinierte ' + 'Hauptrichtung führt.', 'hauptrichtung-ohne-freigabe': 'Die Signalgruppe der Hauptrichtung bekommt in diesem Signalzeitenplan keine Freigabezeit. ' + 'Ohne Freigabefenster gibt es kein Band.', 'keine-umlaufzeit': 'Dieser Signalzeitenplan hat keine Umlaufzeit. Eine Grüne Welle setzt eine gemeinsame ' + 'Umlaufzeit aller beteiligten Anlagen voraus.', };