import { createId } from '../id'; import { roundTo, type Meters, type Seconds } from '../units'; import { computeIntergreen } from '../rilsa/intergreen'; import { intergreenKey } from '../plan/signalPlan'; import { resolveRilsaSettings } from '../rilsa/settings'; import { TRAFFIC_MODE_LABELS } from '../rilsa/constants'; import type { CalculationNote, Movement } from '../rilsa/types'; import { defaultVehicleClass } from '../model/factory'; import { hatHandvorgaben, WEGNACHWEIS_HANDEINGABE, wegnachweisAusLageplan, type Conflict, type Project, type SignalGroup, } from '../model/project'; import { fahrlinienVon, istAuswertbar, planlinieVon, type Lageplan, type Planlinie, } from './lageplan'; import { querungsart, schneideMitBefund, ueberlappung, vermesseWege, type Fahrlinie, } from './vermessung'; /** * Ableitung der Planungsdaten aus dem Lageplan. * * GRUNDSATZ: NICHTS WIRD STILL UEBERSCHRIEBEN * * Die Ableitung erzeugt Vorschlaege und stellt sie dem gegenueber, was im * Projekt steht. Uebernommen wird nur, was der Anwender ausdruecklich * bestaetigt. Andernfalls koennte eine unbedacht verschobene Linie eine von * Hand geprueffte Vermassung zunichtemachen - und das faellt bei zwoelf * Konfliktbeziehungen niemandem auf. * * Was sich ableiten laesst: * - Signalgruppen aus den gezeichneten Stroemen (Vorschlag) * - Vertraeglichkeit aus dem Schnitt der Fahrlinien (belastbar) * - Raeum-/Einfahrweg aus der Geometrie (belastbar) * * Was sich NICHT ableiten laesst: die Phaseneinteilung. Welche vertraeglichen * Gruppen man zu einer Phase zusammenfasst, ist eine Ingenieurentscheidung. */ export interface SignalgruppenVorschlag { readonly planlinieId: string; readonly name: string; readonly linie: Planlinie; /** Bereits mit einer Signalgruppe verknuepft? */ readonly signalGroupId: string | null; readonly status: 'neu' | 'verknuepft' | 'name-abweichend'; } export interface WegeVorschlag { /** Raeumende Linie. */ readonly vonLinieId: string; readonly nachLinieId: string; readonly vonName: string; readonly nachName: string; readonly raeumweg: Meters; readonly einfahrweg: Meters; readonly winkelGrad: number; /** Was im Projekt steht, sofern die Beziehung dort erfasst ist. */ readonly bestehend: { readonly raeumweg: Meters; readonly einfahrweg: Meters } | null; /** Weicht der gemessene Wert vom erfassten ab? */ readonly abweichung: boolean; } /** Warum zwei Stroeme feindlich oder vertraeglich sind. */ export type Vertraeglichkeitsgrund = 'kreuzung' | 'ueberlagerung' | 'gemeinsamer-fahrstreifen' | 'auffaecherung' | 'kein-schnitt'; export interface Beurteilung { readonly feindlich: boolean; /** * Widersprechen Zeichnung und fachliche Erwartung? Dann wird der Vorschlag * gefuehrt, aber nicht vorausgewaehlt - die Entscheidung gehoert dem Planer. */ readonly zuPruefen: boolean; readonly grund: Vertraeglichkeitsgrund; /** Anwendertext fuer Tabelle und Pruefbericht. */ readonly begruendung: string; } export interface VertraeglichkeitVorschlag { readonly aLinieId: string; readonly bLinieId: string; readonly aName: string; readonly bName: string; /** Kreuzen sich die Fahrlinien? */ readonly feindlich: boolean; readonly zuPruefen: boolean; readonly grund: Vertraeglichkeitsgrund; readonly begruendung: string; /** Wie ist es im Projekt erfasst? `null`, wenn beide Gruppen fehlen. */ readonly imProjekt: boolean | null; readonly abweichung: boolean; } /** * Eine Beruehrung, die als Auffaecherung gewertet und deshalb nicht als * Konflikt gefuehrt wurde. * * Wird ausdruecklich ausgewiesen: Das Unterdruecken eines Konflikts ist * sicherheitsrelevant und darf nie stillschweigend geschehen. */ export interface UnterdruecktesPaar { readonly aLinieId: string; readonly bLinieId: string; readonly aName: string; readonly bName: string; readonly anzahl: number; } export interface Ableitung { readonly auswertbar: boolean; readonly signalgruppen: readonly SignalgruppenVorschlag[]; readonly vertraeglichkeit: readonly VertraeglichkeitVorschlag[]; readonly wege: readonly WegeVorschlag[]; readonly unterdrueckt: readonly UnterdruecktesPaar[]; readonly hinweise: readonly CalculationNote[]; } /** * Beurteilt ein Strompaar. * * Drei Faelle, in dieser Reihenfolge: * 1. Punktschnitt -> feindlich. Die Auffaecherung aus derselben Zufahrt ist * hier bereits herausgerechnet (siehe vermessung.ts). * 2. Ueberlagerung -> feindlich, ausser die Stroeme teilen sich einen * Fahrstreifen derselben Zufahrt; dann sind sie vertraeglich, muessen aber * dieselbe Signalgruppe bekommen. * 3. Sonst -> vertraeglich. */ export function beurteile(a: Fahrlinie, b: Fahrlinie, nameA: string, nameB: string): Beurteilung { const befund = schneideMitBefund(a, b); const gleicheHaltlinie = a.bezug?.haltlinieId != null && a.bezug.haltlinieId === b.bezug?.haltlinieId; const nurKfz = a.bezug?.mode === 'kfz' && b.bezug?.mode === 'kfz'; const ersterSchnitt = befund.schnitte[0]; if (ersterSchnitt !== undefined) { // Zwei Kfz-Stroeme derselben Haltlinie, die sich trotzdem schneiden: // fachlich moeglich - Linksabbieger aus Seitenlage, Wendefahrbeziehung -, // ebenso haeufig aber ein zu weit ausholend gezeichneter Bogen. Das ist // eine Entscheidung des Planers und wird nicht vorausgewaehlt. const zuPruefen = gleicheHaltlinie && nurKfz; return { feindlich: true, zuPruefen, grund: 'kreuzung', begruendung: zuPruefen ? `„${nameA}“ und „${nameB}“ beginnen an derselben Haltlinie, kreuzen sich aber ` + `${ersterSchnitt.abstandA.toFixed(1)} m dahinter. Das ist ein echter Konflikt, wenn ein ` + 'Strom die Bahn des anderen schneidet – und ein Zeichenfehler, wenn der Bogen zu weit ' + 'ausholt. Bitte entscheiden Sie.' : `Die Fahrlinien von „${nameA}“ und „${nameB}“ kreuzen sich.`, }; } const ueber = ueberlappung(a, b); if (ueber !== null) { if (gleicheHaltlinie && nurKfz) { return { feindlich: false, zuPruefen: false, grund: 'gemeinsamer-fahrstreifen', begruendung: `„${nameA}“ und „${nameB}“ benutzen über ${ueber.laenge.toFixed(1)} m denselben ` + 'Fahrstreifen. Sie sind verträglich, müssen aber dieselbe Signalgruppe erhalten – ' + 'getrennt signalisieren lässt sich nur, was einen eigenen Fahrstreifen hat.', }; } return { feindlich: true, zuPruefen: false, grund: 'ueberlagerung', begruendung: `Die Korridore von „${nameA}“ und „${nameB}“ überdecken sich auf ` + `${ueber.laenge.toFixed(1)} m. Sie beanspruchen dieselbe Fläche.`, }; } return { feindlich: false, zuPruefen: false, grund: befund.unterdrueckt > 0 ? 'auffaecherung' : 'kein-schnitt', begruendung: befund.unterdrueckt > 0 ? `„${nameA}“ und „${nameB}“ fahren von derselben Haltlinie ab und fächern auf. Ströme ` + 'derselben Zufahrt sind untereinander verträglich – sie bekommen gleichzeitig Grün.' : `Die Fahrlinien von „${nameA}“ und „${nameB}“ berühren sich nicht.`, }; } /** Wertet den Lageplan aus und stellt das Ergebnis dem Projekt gegenueber. */ export function leiteAb(project: Project, lageplan: Lageplan): Ableitung { const hinweise: CalculationNote[] = []; if (lageplan.bild === null) { hinweise.push({ severity: 'hinweis', code: 'kein-bild', message: 'Es ist noch kein Lageplan eingelesen.', }); } if (lageplan.kalibrierung === null) { hinweise.push({ severity: 'fehler', code: 'kein-massstab', message: 'Der Maßstab ist nicht festgelegt. Ziehen Sie eine Strecke bekannter Länge im Plan und ' + 'geben Sie ihre Länge in Metern an - ohne Maßstab lassen sich keine Wege ermitteln.', }); } const zeichnungen = lageplan.linien.filter((l) => l.punkte.length >= 2); const unfertig = lageplan.linien.length - zeichnungen.length; if (unfertig > 0) { hinweise.push({ severity: 'warnung', code: 'linie-unfertig', message: `${unfertig} Fahrlinie(n) bestehen aus weniger als zwei Punkten und werden nicht ausgewertet.`, }); } // Ohne Haltlinie entscheidet allein die Zeichnung - und dann gilt der // gemeinsame Anfangspunkt zweier Stroeme derselben Zufahrt als Kreuzung. // Der Hinweis traegt bewusst die Schwere "warnung": Die Anzeige blendet // blosse Hinweise aus, und das hier ist die einzige Rueckmeldung gegen den // wahrscheinlichsten Anwenderfehler ueberhaupt. const ohneHaltlinie = zeichnungen.filter((l) => l.haltlinieId === null); if (ohneHaltlinie.length > 0 && lageplan.linien.length > 1) { hinweise.push({ severity: 'warnung', code: 'haltlinie-fehlt', message: `${ohneHaltlinie.length} Fahrlinie(n) beginnen an keiner Haltlinie. Solange das so ist, ` + 'entscheidet allein die Zeichnung über die Verträglichkeit; Ströme derselben Zufahrt ' + 'können dadurch als feindlich vorgeschlagen werden, obwohl sie gleichzeitig Grün ' + 'bekommen müssen. Zeichnen Sie die Haltlinie der Zufahrt ein und setzen Sie die ' + 'Fahrlinien darauf an.', }); } if (!istAuswertbar(lageplan)) { return { auswertbar: false, signalgruppen: [], vertraeglichkeit: [], wege: [], unterdrueckt: [], hinweise, }; } const fahrlinien = fahrlinienVon(lageplan); const nachId = new Map(fahrlinien.map((f) => [f.id, f])); return { auswertbar: true, signalgruppen: signalgruppenVorschlaege(project, zeichnungen), vertraeglichkeit: vertraeglichkeitVorschlaege(project, zeichnungen, nachId), wege: wegeVorschlaege(project, zeichnungen, nachId, hinweise), unterdrueckt: unterdrueckteBeruehrungen(zeichnungen, nachId), hinweise, }; } /** * Sammelt die Beruehrungen, die als Auffaecherung gewertet wurden. * * Sie erscheinen in der Oberflaeche als eigener, aufklappbarer Abschnitt. Ein * Pruefer muss nachvollziehen koennen, welche Beruehrungen das Programm nicht * als Konflikt gefuehrt hat und warum. */ function unterdrueckteBeruehrungen( linien: readonly Planlinie[], fahrlinien: ReadonlyMap, ): UnterdruecktesPaar[] { const ergebnis: UnterdruecktesPaar[] = []; for (let i = 0; i < linien.length; i += 1) { for (let j = i + 1; j < linien.length; j += 1) { const a = linien[i]!; const b = linien[j]!; const fa = fahrlinien.get(a.id); const fb = fahrlinien.get(b.id); if (!fa || !fb) continue; const befund = schneideMitBefund(fa, fb); if (befund.unterdrueckt === 0) continue; ergebnis.push({ aLinieId: a.id, bLinieId: b.id, aName: a.name, bName: b.name, anzahl: befund.unterdrueckt, }); } } return ergebnis; } // --- Signalgruppen ---------------------------------------------------------- function signalgruppenVorschlaege( project: Project, linien: readonly Planlinie[], ): SignalgruppenVorschlag[] { return linien.map((linie) => { const verknuepft = linie.signalGroupId === null ? undefined : project.signalGroups.find((g) => g.id === linie.signalGroupId); return { planlinieId: linie.id, name: linie.name, linie, signalGroupId: verknuepft?.id ?? null, status: verknuepft === undefined ? 'neu' : verknuepft.name === linie.name ? 'verknuepft' : 'name-abweichend', }; }); } /** * Legt fuer die gewaehlten Linien Signalgruppen an und verknuepft sie. * Bereits verknuepfte Linien bleiben unberuehrt. */ export function uebernimmSignalgruppen( project: Project, lageplan: Lageplan, planlinienIds: readonly string[], ): { project: Project; lageplan: Lageplan; angelegt: number } { const gewaehlt = new Set(planlinienIds); const neueGruppen: SignalGroup[] = []; const zuordnung = new Map(); for (const linie of lageplan.linien) { if (!gewaehlt.has(linie.id)) continue; if ( linie.signalGroupId !== null && project.signalGroups.some((g) => g.id === linie.signalGroupId) ) { continue; } const gruppe: SignalGroup = { id: createId('sg'), name: linie.name, mode: linie.mode, movement: linie.movement, armId: null, vZul: project.intersection.vZul, lanes: 1, vehicleClass: defaultVehicleClass(linie.mode), minGreenOverride: null, maxGreenOverride: null, reducedMobility: false, // Ausdruecklich `false` wie in factory.ts (createSignalGroup): Eine aus // dem Lageplan angelegte Gruppe ist eine neue Aussage des Bearbeiters, // kein Altbestand ohne das Feld (Fassung 5.4.0, Befund C1). blindenzusatz: false, color: farbeFuer(project.signalGroups.length + neueGruppen.length), }; neueGruppen.push(gruppe); zuordnung.set(linie.id, gruppe.id); } return { project: { ...project, signalGroups: [...project.signalGroups, ...neueGruppen] }, lageplan: { ...lageplan, linien: lageplan.linien.map((l) => zuordnung.has(l.id) ? { ...l, signalGroupId: zuordnung.get(l.id)! } : l, ), }, angelegt: neueGruppen.length, }; } /** * Ordnet eine gezeichnete Fahrlinie einer BESTEHENDEN Signalgruppe zu. * * Damit wird der Regelfall erstmals abbildbar: ein Signalgeber fuer geradeaus * und rechts. Eine Signalgruppe ist die Menge gleich schaltender Signalgeber, * kein Verkehrsstrom - sie darf mehrere Stroeme bedienen. Bisher legte die * Ableitung stur je Linie eine eigene Gruppe an; wer zwei Stroeme auf einen * Signalgeber legen wollte, konnte es gar nicht ausdruecken. * * Drei Sperren, jede aus einem eigenen Grund: * * 1. NUR EINE VERKEHRSART je Gruppe. Fuer Fussgaenger, Rad und Kraftfahrzeuge * gelten verschiedene Mindestfreigabe- und Raeumzeiten; eine gemeinsame * Gruppe koennte sie nicht zugleich einhalten. * 2. NUR STROEME EINER HALTLINIE. Gleich schaltende Signalgeber stehen an * einem Querschnitt. Stroeme verschiedener Haltlinien zusammenzulegen * hiesse, zwei Aufstellbereiche mit einem Geber zu regeln. * 3. NIE SICH KREUZENDE STROEME. Sie erhielten sonst gleichzeitig Freigabe * fuer einander feindliche Bewegungen - und zwar unbemerkt, denn zwischen * Stroemen derselben Gruppe gibt es keine Zwischenzeit. * * Die Zuordnung wird nicht erzwungen: Ist sie unzulaessig, bleibt das Projekt * unveraendert und der Grund wird zurueckgegeben. */ export function verbindeMitGruppe( project: Project, lageplan: Lageplan, linieId: string, gruppeId: string | null, ): { lageplan: Lageplan; hinweis: CalculationNote | null } { const linie = planlinieVon(lageplan, linieId); if (linie === undefined) return { lageplan, hinweis: null }; const loesen = (): { lageplan: Lageplan; hinweis: CalculationNote | null } => ({ lageplan: { ...lageplan, linien: lageplan.linien.map((l) => l.id === linieId ? { ...l, signalGroupId: gruppeId } : l, ), }, hinweis: null, }); if (gruppeId === null) return loesen(); const gruppe = project.signalGroups.find((g) => g.id === gruppeId); if (gruppe === undefined) return { lageplan, hinweis: null }; const verweigere = ( code: string, message: string, ): { lageplan: Lageplan; hinweis: CalculationNote } => ({ lageplan, hinweis: { severity: 'warnung', code, message }, }); if (gruppe.mode !== linie.mode) { return verweigere( 'gruppe-fremde-verkehrsart', `„${gruppe.name}“ schaltet ${TRAFFIC_MODE_LABELS[gruppe.mode]}, „${linie.name}“ ist ` + `${TRAFFIC_MODE_LABELS[linie.mode]}. Eine Signalgruppe bedient nur eine Verkehrsart – ` + 'die Mindestfreigabe- und Räumzeiten unterscheiden sich.', ); } const geschwister = lageplan.linien.filter( (l) => l.id !== linieId && l.signalGroupId === gruppeId, ); const fremdeHaltlinie = geschwister.find((l) => l.haltlinieId !== linie.haltlinieId); if (fremdeHaltlinie !== undefined) { return verweigere( 'gruppe-fremde-haltlinie', `„${gruppe.name}“ schaltet bereits „${fremdeHaltlinie.name}“ an einer anderen Haltlinie. ` + 'Gleich schaltende Signalgeber stehen an einem Querschnitt.', ); } const fahrlinien = new Map(fahrlinienVon(lageplan).map((f) => [f.id, f])); const eigene = fahrlinien.get(linieId); if (eigene !== undefined) { for (const bruder of geschwister) { const andere = fahrlinien.get(bruder.id); if (andere === undefined) continue; if (beurteile(eigene, andere, linie.name, bruder.name).feindlich) { return verweigere( 'gruppe-kreuzende-stroeme', `„${linie.name}“ und „${bruder.name}“ kreuzen sich. Sie dürfen nicht denselben ` + 'Signalgeber bekommen – zwischen Strömen einer Gruppe gibt es keine Zwischenzeit, ' + 'der Konflikt bliebe ungesichert.', ); } } } return loesen(); } const FARBEN = [ '#2f6fd0', '#e07b39', '#4a9e6a', '#a05ec4', '#c4526a', '#3d9bb0', '#8a7b3a', '#6b6f7a', ] as const; function farbeFuer(index: number): string { return FARBEN[index % FARBEN.length] ?? '#2f6fd0'; } // --- Vertraeglichkeit ------------------------------------------------------- function vertraeglichkeitVorschlaege( project: Project, linien: readonly Planlinie[], fahrlinien: ReadonlyMap, ): VertraeglichkeitVorschlag[] { const ergebnis: VertraeglichkeitVorschlag[] = []; for (let i = 0; i < linien.length; i += 1) { for (let j = i + 1; j < linien.length; j += 1) { const a = linien[i]!; const b = linien[j]!; const fa = fahrlinien.get(a.id); const fb = fahrlinien.get(b.id); if (!fa || !fb) continue; const urteil = beurteile(fa, fb, a.name, b.name); const imProjekt = konfliktImProjekt(project, a, b); ergebnis.push({ aLinieId: a.id, bLinieId: b.id, aName: a.name, bName: b.name, feindlich: urteil.feindlich, zuPruefen: urteil.zuPruefen, grund: urteil.grund, begruendung: urteil.begruendung, imProjekt, abweichung: imProjekt !== null && imProjekt !== urteil.feindlich, }); } } return ergebnis; } /** `null`, wenn eine der beiden Linien noch keiner Signalgruppe zugeordnet ist. */ function konfliktImProjekt(project: Project, a: Planlinie, b: Planlinie): boolean | null { if (a.signalGroupId === null || b.signalGroupId === null) return null; const gruppenDa = project.signalGroups.some((g) => g.id === a.signalGroupId) && project.signalGroups.some((g) => g.id === b.signalGroupId); if (!gruppenDa) return null; return project.conflicts.some( (c) => (c.fromId === a.signalGroupId && c.toId === b.signalGroupId) || (c.fromId === b.signalGroupId && c.toId === a.signalGroupId), ); } // --- Wege ------------------------------------------------------------------- function wegeVorschlaege( project: Project, linien: readonly Planlinie[], fahrlinien: ReadonlyMap, hinweise: CalculationNote[], ): WegeVorschlag[] { const ergebnis: WegeVorschlag[] = []; for (const von of linien) { for (const nach of linien) { if (von.id === nach.id) continue; // Zwei Stroeme derselben Signalgruppe schalten stets gleich. Zwischen // ihnen gibt es keinen Signalwechsel und damit keine Zwischenzeit - sie // waere schalttechnisch nicht darstellbar. Kreuzen sie sich, ist nicht // die Zwischenzeit das Problem, sondern die Buendelung. if (von.signalGroupId !== null && von.signalGroupId === nach.signalGroupId) continue; const fv = fahrlinien.get(von.id); const fn = fahrlinien.get(nach.id); if (!fv || !fn) continue; // Nur feindliche Beziehungen brauchen eine Zwischenzeit. Ohne diese // Pruefung entstuenden Vorschlaege fuer Stroeme, die gleichzeitig Gruen // bekommen - etwa fuer die drei Fahrbeziehungen einer einstreifigen // Zufahrt, deren Korridore sich zwangslaeufig ueberdecken. if (!beurteile(fv, fn, von.name, nach.name).feindlich) continue; const wege = vermesseWege(fv, fn); if (wege === null) continue; // Nicht bei einer Querung: Dort ist der Einfahrweg 0 m die Regel und // kein Zeichenfehler - der einfahrende Fussgaenger steht am // Fahrbahnrand, und die Vermessung setzt ihn deshalb auf 0 m // (`vermesseWege`). Die Meldung stuende sonst an jeder Furt. if (wege.einfahrweg <= 0 && querungsart(fn) === null) { hinweise.push({ severity: 'warnung', code: 'einfahrweg-null', message: `Für "${von.name}" nach "${nach.name}" liegt der Konfliktbereich unmittelbar an der ` + 'Haltlinie des einfahrenden Stroms. Prüfen Sie, ob die Haltlinie richtig gesetzt ist.', }); } const bestehend = bestehendeWege(project, von, nach); ergebnis.push({ vonLinieId: von.id, nachLinieId: nach.id, vonName: von.name, nachName: nach.name, raeumweg: wege.raeumweg, einfahrweg: wege.einfahrweg, winkelGrad: wege.winkelGrad, bestehend, abweichung: bestehend !== null && (Math.abs(bestehend.raeumweg - wege.raeumweg) > 0.05 || Math.abs(bestehend.einfahrweg - wege.einfahrweg) > 0.05), }); } } return ergebnis; } function bestehendeWege( project: Project, von: Planlinie, nach: Planlinie, ): { raeumweg: Meters; einfahrweg: Meters } | null { if (von.signalGroupId === null || nach.signalGroupId === null) return null; const konflikt = project.conflicts.find( (c) => c.fromId === von.signalGroupId && c.toId === nach.signalGroupId, ); if (!konflikt) return null; return { raeumweg: roundTo(konflikt.clearingDistance, 2), einfahrweg: roundTo(konflikt.enteringDistance, 2), }; } /** * Uebernimmt Vertraeglichkeit und Wege der gewaehlten Beziehungen in das * Projekt. Nicht gewaehlte Beziehungen bleiben unveraendert. * * DIE EINZIGE STELLE IM PROGRAMM, die den Herkunftsnachweis "aus dem Lageplan" * vergibt - und sie vergibt ihn nur fuer die Beziehungen, deren Wege sie im * selben Zug schreibt. Jede spaetere Aenderung an diesen Werten setzt ihn ueber * `aendereKonflikt` wieder zurueck. * * `now` ist einspeisbar, damit der Zeitstempel in Tests reproduzierbar bleibt. */ export function uebernimmWege( project: Project, lageplan: Lageplan, ableitung: Ableitung, gewaehlt: readonly string[], now: Date = new Date(), ): { project: Project; uebernommen: number } { const schluessel = new Set(gewaehlt); // Mit der Anlagenart des Projekts (Fassung 5.4.0, Befund C10) - // derselbe Kennwertsatz, mit dem auch der Signalzeitenplan rechnet. const defaults = resolveRilsaSettings( project.settings.rilsa, undefined, project.anlagenart, ).defaults; /** * Verdichtung auf Signalgruppenpaare. * * Eine Signalgruppe kann mehrere Stroeme bedienen - geradeaus und rechts aus * einem Fahrstreifen etwa. Dann fallen fuer EIN Gruppenpaar mehrere * Strompaare an, und es gilt das unguenstigste von ihnen: Die Gruppe erbt die * groesste Zwischenzeit, die einer ihrer Stroeme verlangt. * * Massgebend ist die groesste ZWISCHENZEIT, nicht der laengste Raeumweg. * Raeum- und Einfahrgeschwindigkeit haengen an Verkehrsart und Fahrbeziehung * des einzelnen Stroms; ein kurzer Weg eines Abbiegers kann laenger dauern * als ein langer Weg geradeaus. Deshalb wird je Paar gerechnet und verglichen. * * UEBERNOMMEN WIRD DIE FAHRBEZIEHUNG MIT (Fassung 5.11.0). Bis dahin erbte * die Gruppe nur die GEOMETRIE des Siegerpaares; der Planaufbau rechnete die * Beziehung danach mit der Fahrbeziehung der GRUPPE neu (signalPlan.ts, * `raeumenderStrom`). Wo der Rechtsabbieger den Ausschlag gab, raeumte der * Plan dessen Bogen mit 10,0 statt 7,0 m/s - die Zusage zwei Absaetze weiter * oben war gebrochen, und die Bemerkung nannte neben 7 s gedruckter * Zwischenzeit die massgebenden 9 s. Deshalb schreibt die Uebernahme jetzt * auch `raeumbeziehung`, sobald das Siegerpaar eine andere Fahrbeziehung hat * als seine Gruppe - und entfernt das Feld, sobald es dieselbe hat. * * Ohne diese Verdichtung schriebe schlicht das zuletzt behandelte Strompaar * den Wert - welches das ist, haengt an der Reihenfolge der Linien. * * DAS PAAR WIRD VERSCHACHTELT GEFUEHRT, nicht als zusammengeklebte * Zeichenkette (Fassung 5.10.0) - dieselbe Form wie in `parseConflicts`. Hier * stand `${fromId}|${toId}`, und der Schluessel wurde weiter unten mit * `split('|')` wieder zerlegt. Eine Signalgruppenkennung kommt ungeprueft aus * der Datei (nur leer und doppelt wird beanstandet, kein Zeichenrepertoire), * und mit einem "|" darin ging beides kaputt: Die Paare ("a|b", "c") und * ("a", "b|c") ergaben denselben Schluessel "a|b|c" - eine der beiden * Uebernahmen verschwand still -, und das Zerlegen lieferte fuer "sg|a|sg-b" * die Beziehung "sg" -> "a", also zwei Kennungen, die es im Projekt gar nicht * gibt. Die gemeinte Beziehung blieb ohne die vermessenen Wege stehen; ihre * Zwischenzeit haengt dann weiter am alten Raeumweg - die gefaehrliche * Richtung, wenn der kuerzer ist. * * Verschachtelt braucht es kein Zerlegen mehr: Beide Kennungen stehen als * Schluessel der aeusseren und der inneren Karte. */ /** * Das Siegerpaar eines Gruppenpaares. * * `raeumbeziehung` steht nur da, wo die Fahrbeziehung des raeumenden STROMS * eine andere ist als die seiner Gruppe; sonst `undefined`, und die * Beziehung fuehrt das Feld gar nicht (siehe `Conflict.raeumbeziehung`). */ interface Siegerpaar { readonly weg: WegeVorschlag; readonly zwischenzeit: Seconds; /** * Das ungerundete tue + tr - te desselben Paares. * * ES ENTSCHEIDET DEN GLEICHSTAND (Fassung 5.24.0). Verglichen wird die * aufgerundete Zwischenzeit, und die trifft haeufig zusammen: Am * mitgelieferten Beispielknotenpunkt liegen fuer dasselbe Gruppenpaar * 4,100 s und 4,591 s nebeneinander, beide 5 s (bis 5.42.1 4,013 s, als * der einfahrende Fussgaenger noch 0,13 m Einfahrweg hatte). Bis 5.23.0 * gewann der zuerst gezeichnete Strom - also die Reihenfolge der Linien im Lageplan, * die ueber die Sache nichts aussagt. Uebernommen wurde damit auch die * GEOMETRIE des Unterlegenen, und die bleibt stehen: Aendert sich spaeter * die Gelbzeit, die zulaessige Hoechstgeschwindigkeit oder der * Ueberfahrzeit-Ansatz, laufen die beiden auseinander, und der Plan * rechnet mit dem schwaecheren Ansatz weiter. Der ungerundete Wert * unterscheidet die beiden und ist keine zweite Entscheidung, sondern * dieselbe eine Stelle genauer gelesen. */ readonly roh: Seconds; readonly raeumbeziehung: Movement | undefined; } const beste = new Map>(); for (const weg of ableitung.wege) { if (!schluessel.has(wegSchluessel(weg))) continue; const von = planlinieVon(lageplan, weg.vonLinieId); const nach = planlinieVon(lageplan, weg.nachLinieId); if (!von?.signalGroupId || !nach?.signalGroupId) continue; // Letzte Sperre vor dem Schreiben: Eine Signalgruppe kann nicht mit sich // selbst in Konflikt stehen. Bisher fing das erst das Wiedereinlesen ab - // der Fehler war im Arbeitsstand vorhanden und nach dem Speichern weg. if (von.signalGroupId === nach.signalGroupId) continue; const gruppeVon = project.signalGroups.find((g) => g.id === von.signalGroupId); const gruppeNach = project.signalGroups.find((g) => g.id === nach.signalGroupId); if (!gruppeVon || !gruppeNach) continue; /* * DIE BEIDEN RECHENANSAETZE DES PROJEKTS GEHOEREN IN DIESEN VERGLEICH. * * Hier wird entschieden, WELCHER Weg des Gruppenpaares massgebend ist und * damit gespeichert wird. Die Entscheidung fiel zuvor nach * einer anderen Rechnung als der des Plans: ohne den gewaehlten * Ueberfahrzeit-Ansatz und ohne das Merkmal "enger Innenradius" der bereits * vorhandenen Beziehung. Nachgerechnet: zwei Wege desselben Paares, * geradeaus s0 = 30 m und abbiegend s0 = 20 m, je te = 0 - mit 'gelbzeit' * ist der abbiegende Weg massgebend (6,714 s gegen 6,600 s), mit 'fest' der * geradeausfahrende (6,600 s gegen 5,714 s). Uebernommen wurde in beiden * Faellen s0 = 20 m, und der Plan druckte danach 6 s statt 7 s. * * Das Merkmal kommt aus der VORHANDENEN Beziehung: Eine neu angelegte * fuehrt es nicht (createConflict laesst das Feld weg), und eine bestehende * behaelt es - der Vergleich muss deshalb mit dem Merkmal rechnen, das * hinterher auch gilt. */ const bestehenderKonflikt = project.conflicts.find( (c) => c.fromId === von.signalGroupId && c.toId === nach.signalGroupId, ); const ergebnisPaar = computeIntergreen( { mode: von.mode, movement: von.movement, vehicleClass: gruppeVon.vehicleClass, clearingDistance: weg.raeumweg, vZul: gruppeVon.vZul, reducedMobility: gruppeVon.reducedMobility, ueberfahrzeitAnsatz: project.settings.ueberfahrzeitAnsatz, ...(bestehenderKonflikt?.engerRadius === true ? { engerRadius: true } : {}), ...(bestehenderKonflikt?.haltVorKnoten === true ? { haltVorKnoten: true } : {}), }, { mode: nach.mode, movement: nach.movement, enteringDistance: weg.einfahrweg, }, defaults, ); const zwischenzeit = ergebnisPaar.intergreen; const roh = ergebnisPaar.raw; let zuVon = beste.get(von.signalGroupId); if (zuVon === undefined) { zuVon = new Map(); beste.set(von.signalGroupId, zuVon); } const bisher = zuVon.get(nach.signalGroupId); /* * Erst die aufgerundete Zwischenzeit - sie ist die Zahl, die im Plan * steht -, bei Gleichstand der ungerundete Wert. Siehe `Siegerpaar.roh`: * Bis 5.23.0 stand hier nur der erste Vergleich, und was gleich aussah, * entschied die Zeichenreihenfolge. */ const besser = bisher === undefined || zwischenzeit > bisher.zwischenzeit || (zwischenzeit === bisher.zwischenzeit && roh > bisher.roh); if (besser) { zuVon.set(nach.signalGroupId, { weg, zwischenzeit, roh, // Mit derselben Fahrbeziehung, mit der die Zwischenzeit oben gerechnet // wurde - sonst rechnet der Plan aus demselben Weg eine andere Zahl. raeumbeziehung: von.movement === gruppeVon.movement ? undefined : von.movement, }); } } let ergebnis = project; let uebernommen = 0; const nachweis = wegnachweisAusLageplan(now); for (const [fromId, zuVon] of beste) { for (const [toId, eintrag] of zuVon) { uebernommen += 1; /** * Die Bemerkung haelt fest, WELCHES Strompaar den Ausschlag gab - eine * Angabe, die sonst nirgends steht und die man braucht, um die Zahl im * Lageplan wiederzufinden. * * Sie ist bewusst als Vermerk ueber den Vorgang formuliert und nicht mehr * als Aussage ueber den heutigen Wert ("Aus dem Lageplan vermessen"): Ein * Satz in der Gegenwartsform bliebe stehen, wenn der Raeumweg danach von * Hand geaendert wird, und widerspraeche dann dem Herkunftsnachweis, der * gleich daneben im Ausdruck steht. Woher die Wege stammen, sagt allein * `wegnachweis`. */ const note = `Bei der Übernahme aus dem Lageplan maßgebend: "${eintrag.weg.vonName}" räumt vor ` + `"${eintrag.weg.nachName}" (${eintrag.zwischenzeit} s).`; const vorhanden = ergebnis.conflicts.find((c) => c.fromId === fromId && c.toId === toId); if (vorhanden) { ergebnis = { ...ergebnis, conflicts: ergebnis.conflicts.map((c) => // Geschrieben wird ueber DASSELBE Paar, ueber das oben gesucht // wurde - nicht ueber `vorhanden.id`. Traegt eine zweite Beziehung // dieselbe Kennung, bekaeme sie sonst Raeumweg, Einfahrweg und // Bemerkung dieser hier mit, obwohl sie zu ganz anderen // Signalgruppen gehoert und niemand sie gewaehlt hat. Ist der // fremde Raeumweg kuerzer als der wahre, faellt ihre Zwischenzeit // zu kurz aus - die gefaehrliche Richtung. Die Neuvergabe der // Kennungen beim Einlesen (schema.ts) entschaerft nur Daten, die // durch parseProject laufen; hier haengt das Verhalten nun an gar // keiner Eindeutigkeit mehr. c.fromId === fromId && c.toId === toId ? mitRaeumbeziehung( { ...c, clearingDistance: eintrag.weg.raeumweg, enteringDistance: eintrag.weg.einfahrweg, // Die Wege stammen jetzt aus dem Lageplan - die Zwischenzeit // aber nur dann, wenn nicht daneben eine Geschwindigkeit oder // die Zwischenzeit selbst von Hand vorgegeben ist. Diese // Vorgaben werden nicht angetastet: Sie zu loeschen hiesse, // eine bewusste Entscheidung stillschweigend zu verwerfen. wegnachweis: hatHandvorgaben(c) ? WEGNACHWEIS_HANDEINGABE : nachweis, note, }, eintrag.raeumbeziehung, ) : c, ), }; } else { ergebnis = { ...ergebnis, conflicts: [ ...ergebnis.conflicts, { id: createId('cf'), fromId, toId, clearingDistance: eintrag.weg.raeumweg, enteringDistance: eintrag.weg.einfahrweg, clearingSpeedOverride: null, enteringSpeedOverride: null, crossingTimeOverride: null, manualIntergreen: null, // Nur, wenn der Sieger eine andere Fahrbeziehung hat als seine // Gruppe - eine neue Beziehung fuehrt das Feld sonst nicht. ...(eintrag.raeumbeziehung === undefined ? {} : { raeumbeziehung: eintrag.raeumbeziehung }), wegnachweis: nachweis, note, }, ], }; } } } return { project: ergebnis, uebernommen }; } /** * Setzt die Fahrbeziehung des massgebenden Raeumstroms an einer Beziehung - * oder ENTFERNT sie. * * Das Entfernen ist der Grund, aus dem es diese Funktion gibt: Ein blosses * `{ ...c, raeumbeziehung }` liesse eine aeltere Angabe stehen, wenn diesmal * ein Strom mit der Fahrbeziehung seiner Gruppe gewonnen hat. Der Plan raeumte * den neuen Weg dann mit der Geschwindigkeit des alten Siegers - beim Wechsel * von 'rechts' auf 'geradeaus' zu langsam, beim umgekehrten Wechsel zu * schnell, und das ist die gefaehrliche Richtung. * * `exactOptionalPropertyTypes` verbietet den kuerzeren Weg ueber ein * ausdrueckliches `undefined`; das Feld wird deshalb aus der Kopie * herausgelassen. */ function mitRaeumbeziehung(conflict: Conflict, raeumbeziehung: Movement | undefined): Conflict { const { raeumbeziehung: _bisher, ...ohne } = conflict; return raeumbeziehung === undefined ? ohne : { ...ohne, raeumbeziehung }; } /** * Eindeutiger Schluessel einer gerichteten Beziehung im Vorschlag. * * EINDEUTIG AUCH BEI EINER KENNUNG MIT DEM TRENNZEICHEN (Fassung 5.11.0): Hier * stand `${vonLinieId}->${nachLinieId}`. Eine PLANLINIENKENNUNG kommt noch * ungeprueter aus der Datei als eine Signalgruppenkennung - `parseLageplan` * vergibt nur die leere und die doppelte neu und prueft kein * Zeichenrepertoire -, und mit "->" darin fielen die Paare ("a->b", "c") und * ("a", "b->c") auf denselben Schluessel "a->b->c". * * Der Schaden lag nicht in der Ableitung, sondern in der Uebernahme: Die * Auswahl des Anwenders geht als Menge dieser Schluessel durch `uebernimmWege` * (Zeile mit `schluessel.has(...)`). Bei einer Kollision wurde ein zweites, * NICHT angehaktes Strompaar mitgeschrieben - sein von Hand eingetragener, * bewusst laengerer Raeumweg fiel auf den gemessenen, die Zwischenzeit wurde * kuerzer (die gefaehrliche Richtung), und die Beziehung trug danach den * Herkunftsnachweis "aus dem Lageplan", obwohl niemand sie ausgewaehlt hatte. * Umgekehrt blieb eine angehakte Beziehung ungeschrieben. * * Genommen wird dafuer `intergreenKey` - dieselbe Maskierung, mit der * `signalPlan.ts` und `validation/engine.ts` seit Befund 61 ihre Paarschluessel * bilden. Der Trennstrich ist danach der einzige im Schluessel, und die * Zuordnung ist umkehrbar. Der Schluessel ist ein reines Auswahlmerkmal: Er * wird nirgends zerlegt, sondern nur verglichen. */ export function wegSchluessel(weg: Pick): string { return intergreenKey(weg.vonLinieId, weg.nachLinieId); } /** Warum ein fuer sich vertraegliches Strompaar seine Beziehung nicht aufhebt. */ export type Aufhebungssperre = 'kreuzender-strom' | 'unfertige-linie'; /** * Anwendertext je Sperre. * * Er entsteht hier und nicht in der Ansicht: Ob eine Beziehung stehen bleibt, * ist eine fachliche Entscheidung, und ihr Grund gehoert zu ihr - wie die * `begruendung` einer Beurteilung weiter oben. */ const AUFHEBUNGSSPERRE_GRUND: Readonly> = { 'kreuzender-strom': 'ein anderer Strom derselben Signalgruppen kreuzt – die Beziehung bleibt bestehen', 'unfertige-linie': 'ein Strom derselben Signalgruppen ist noch nicht fertig gezeichnet', }; /** Ein Strompaar, das vertraeglich und erfasst ist, dessen Beziehung aber bleibt. */ export interface GesperrtesPaar { readonly paar: VertraeglichkeitVorschlag; readonly sperre: Aufhebungssperre; readonly begruendung: string; } /** Ein Signalgruppenpaar, dessen erfasste Beziehung der Plan nicht mehr traegt. */ export interface AufhebbaresGruppenpaar { readonly ersteId: string; readonly zweiteId: string; /** Die vertraeglichen, im Projekt erfassten Strompaare dieses Gruppenpaares. */ readonly paare: readonly VertraeglichkeitVorschlag[]; } export interface Aufhebbarkeit { readonly aufhebbar: readonly AufhebbaresGruppenpaar[]; readonly gesperrt: readonly GesperrtesPaar[]; } /** * VERDICHTUNG AUF SIGNALGRUPPENPAARE - wie in uebernimmWege. * * Die Vorschlaege stehen je STROMPAAR, der Konflikt steht je * SIGNALGRUPPENPAAR. Eine Signalgruppe darf mehrere Stroeme fuehren - ein * Signalgeber fuer geradeaus und rechts ist der Regelfall -, und dann gehoeren * zu einem Gruppenpaar mehrere Strompaare. Massgebend ist das unguenstigste: * Ein Gruppenpaar ist feindlich, sobald EIN Strompaar feindlich ist. * Aufgehoben wird deshalb nur, wenn ALLE Strompaare dieses Gruppenpaares * vertraeglich sind. * * Ohne diese Verdichtung riss ein vertraegliches Strompaar (geradeaus gegen * eine Furt) die Beziehung der beiden Gruppen mit - und mit ihr die * Zwischenzeit des feindlichen Strompaares derselben Gruppen (Rechtsabbieger * gegen dieselbe Furt). Zwischen Stroemen einer Gruppe gibt es keine * Zwischenzeit; der Konflikt waere danach ungesichert, und niemand saehe es. * * "ALLE Strompaare" heisst wirklich alle - auch die, ueber die der Plan noch * nichts sagt. Eine Fahrlinie mit weniger als zwei Punkten beschreibt keinen * Weg; leiteAb nimmt sie von der Auswertung aus und meldet "linie-unfertig". * Zu ihr steht kein Strompaar in den Vorschlaegen, und ohne die Sperre unten * saehe ein Gruppenpaar allein deshalb vertraeglich aus, weil der halb * gezeichnete Strom fehlt. Gruppen mit einer unfertigen Linie sind deshalb vom * Aufheben ausgenommen, bis die Linie zu Ende gezeichnet ist. * * DIESE FUNKTION IST DIE EINZIGE BESCHREIBUNG DIESER ENTSCHEIDUNG. Sie wird * ausgewiesen, weil nicht nur `hebeVertraeglicheAuf` sie braucht, sondern auch * die Ansicht: Der Warnbalken "n erfasste Konflikte kreuzen sich im Plan nicht" * und der Bestaetigungstext filterten zuvor selbst je Linienpaar. Sie zaehlten * damit Paare, die gar nicht aufgehoben wurden, kuendigten sie im Fenster an * und meldeten danach "0 Konflikt(e) aufgehoben" - der Balken blieb stehen und * war ueber diesen Weg nie abzutragen. * * `gesperrt` fuehrt die uebrigen Paare mit ihrem Grund: Ein vertraegliches * Strompaar, dessen Beziehung stehen bleibt, ist kein Versehen, sondern ein * Ergebnis - und der Anwender muss es nachlesen koennen, ohne in den Quelltext * zu sehen. */ export function pruefeAufhebbarkeit(lageplan: Lageplan, ableitung: Ableitung): Aufhebbarkeit { interface Sammlung { readonly ersteId: string; readonly zweiteId: string; /** Ist irgendein Strompaar dieser beiden Gruppen feindlich? */ feindlich: boolean; /** Die vertraeglichen Strompaare, deren Beziehung im Projekt erfasst ist. */ readonly erfasstVertraeglich: VertraeglichkeitVorschlag[]; } // Gruppen, zu denen eine nicht auswertbare Fahrlinie gehoert. Zu ihnen fehlen // Strompaare in den Vorschlaegen; ueber sie laesst sich nichts aussagen. const unfertigeGruppen = new Set(); for (const linie of lageplan.linien) { if (linie.punkte.length < 2 && linie.signalGroupId !== null) { unfertigeGruppen.add(linie.signalGroupId); } } /* * VERSCHACHTELT, nicht als zusammengeklebte Zeichenkette (Fassung 5.10.0) - * dieselbe Form wie in `parseConflicts` und in `uebernimmWege`. Hier stand * `${ersteId}|${zweiteId}`. Mit einem "|" in einer Signalgruppenkennung * fielen die Gruppenpaare ("a|b", "c") und ("a", "b|c") auf denselben * Schluessel: Ein feindliches Strompaar des einen sperrte die Beziehung des * anderen mit einer Begruendung, die zu fremden Signalgruppen gehoert, und * `aufhebbar` wies das zuerst eingetragene Gruppenpaar aus - das zweite * verschwand samt seinen Strompaaren. Umgekehrt entging dem `unfertig` eine * unfertige Linie des verdeckten Paares, und eine Beziehung liess sich * aufheben, obwohl ueber sie noch nichts feststeht. */ const gruppenpaare = new Map>(); for (const paar of ableitung.vertraeglichkeit) { const a = planlinieVon(lageplan, paar.aLinieId); const b = planlinieVon(lageplan, paar.bLinieId); if (!a?.signalGroupId || !b?.signalGroupId) continue; // Zwei Stroeme derselben Gruppe schalten stets gleich. Zwischen ihnen gibt // es keine Beziehung, die sich aufheben liesse. if (a.signalGroupId === b.signalGroupId) continue; // Der Schluessel ist ungerichtet: Beide Fahrtrichtungen der Beziehung // gehoeren zu demselben Gruppenpaar und werden zusammen beurteilt. const ersteId = a.signalGroupId < b.signalGroupId ? a.signalGroupId : b.signalGroupId; const zweiteId = a.signalGroupId < b.signalGroupId ? b.signalGroupId : a.signalGroupId; let zuErster = gruppenpaare.get(ersteId); if (zuErster === undefined) { zuErster = new Map(); gruppenpaare.set(ersteId, zuErster); } let eintrag = zuErster.get(zweiteId); if (eintrag === undefined) { eintrag = { ersteId, zweiteId, feindlich: false, erfasstVertraeglich: [] }; zuErster.set(zweiteId, eintrag); } if (paar.feindlich) eintrag.feindlich = true; else if (paar.imProjekt === true) eintrag.erfasstVertraeglich.push(paar); } const aufhebbar: AufhebbaresGruppenpaar[] = []; const gesperrt: GesperrtesPaar[] = []; for (const zuErster of gruppenpaare.values()) { for (const eintrag of zuErster.values()) { // Nichts erfasst, nichts aufzuheben - das Gruppenpaar taucht in keiner der // beiden Listen auf. if (eintrag.erfasstVertraeglich.length === 0) continue; // Ein ungeprueftes Strompaar wiegt wie ein feindliches: Solange eine der // beiden Gruppen eine unfertige Linie fuehrt, bleibt die Beziehung stehen. const unfertig = unfertigeGruppen.has(eintrag.ersteId) || unfertigeGruppen.has(eintrag.zweiteId); if (!eintrag.feindlich && !unfertig) { aufhebbar.push({ ersteId: eintrag.ersteId, zweiteId: eintrag.zweiteId, paare: eintrag.erfasstVertraeglich, }); continue; } // Der kreuzende Strom ist die belegte Aussage und geht dem noch // ungezeichneten vor: Er bleibt der Grund, auch wenn daneben eine Linie // unfertig ist. const sperre: Aufhebungssperre = eintrag.feindlich ? 'kreuzender-strom' : 'unfertige-linie'; for (const paar of eintrag.erfasstVertraeglich) { gesperrt.push({ paar, sperre, begruendung: AUFHEBUNGSSPERRE_GRUND[sperre] }); } } } return { aufhebbar, gesperrt }; } /** * Hebt Konflikte auf, die der Lageplan als vertraeglich ausweist. * Getrennte Funktion, weil das Aufheben eines Konflikts sicherheitsrelevant ist * und deshalb einzeln bestaetigt werden muss. * * WAS aufgehoben wird, entscheidet `pruefeAufhebbarkeit` - dieselbe Funktion, * aus der die Ansicht ihren Warnbalken und ihren Bestaetigungstext bildet. */ export function hebeVertraeglicheAuf( project: Project, lageplan: Lageplan, ableitung: Ableitung, ): { project: Project; aufgehoben: number } { let ergebnis = project; let anzahl = 0; for (const gruppenpaar of pruefeAufhebbarkeit(lageplan, ableitung).aufhebbar) { const vorher = ergebnis.conflicts.length; ergebnis = { ...ergebnis, conflicts: ergebnis.conflicts.filter( (c) => !( (c.fromId === gruppenpaar.ersteId && c.toId === gruppenpaar.zweiteId) || (c.fromId === gruppenpaar.zweiteId && c.toId === gruppenpaar.ersteId) ), ), }; if (ergebnis.conflicts.length !== vorher) anzahl += 1; } return { project: ergebnis, aufgehoben: anzahl }; }