import { describe, expect, it } from 'vitest'; import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung'; import { REGELBREITE } from '@/domain/geometrie/vermessung'; import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import { createEmptyProject } from '@/domain/model/factory'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { validateProject } from '@/domain/validation/engine'; import type { Movement } from '@/domain/rilsa/types'; import type { Project } from '@/domain/model/project'; /** * Vollstaendiger vierarmiger Knotenpunkt - die Gesamtkontrolle. * * Vier Zufahrten mit je geradeaus, links und rechts, dazu vier * Fussgaengerfurten: sechzehn Stroeme an acht Haltlinien. Das ist der Fall, * den die Aenderung tragen muss, und er wird hier vollstaendig durchgerechnet - * von der Zeichnung bis zum ausgabefaehigen Signalzeitenplan. * * Die Geometrie steht als Vorschrift, nicht als Zahlenliste: Jede Zufahrt * entsteht aus derselben Formel, nur gedreht. Dadurch laesst sich jede Zahl von * Hand nachrechnen - und der Fall bleibt lesbar. * * MASSE: Rechtsverkehr, Fahrbahn 14 m, Fahrstreifen 3,5 m, Haltlinie 12 m und * Furt 9 m vor der Knotenpunktmitte. 1 Bildpunkt = 0,1 m. */ const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); const KANTE = 90; const PX = 10; const MITTE = { x: 45, y: 45 }; const HALB = 7; const ACHSE_INNEN = 1.75; const ACHSE_AUSSEN = 5.25; const R_LINKS = 12; const R_RECHTS = 6; const HALTLINIE_VOR = 12; const FURT_VOR = 9; const AUSLAUF = 40; type Vektor = { x: number; y: number }; /** Rechts des Fahrers - bei y nach unten ist das (-fy, fx). */ const rechts = (v: Vektor): Vektor => ({ x: -v.y, y: v.x }); const plus = (a: Vektor, ...teile: readonly { v: Vektor; s: number }[]): Vektor => teile.reduce((p, t) => ({ x: p.x + t.v.x * t.s, y: p.y + t.v.y * t.s }), a); const px = (p: Vektor): Vektor => ({ x: p.x * PX, y: p.y * PX }); /** Kreisbogen - Abbiegevorgaenge als Eckzug erzeugen Scheinkonflikte. */ function bogen(mittelpunkt: Vektor, von: Vektor, bis: Vektor, stuecke = 8): Vektor[] { const radius = Math.hypot(von.x - mittelpunkt.x, von.y - mittelpunkt.y); const a0 = Math.atan2(von.y - mittelpunkt.y, von.x - mittelpunkt.x); let a1 = Math.atan2(bis.y - mittelpunkt.y, bis.x - mittelpunkt.x); while (a1 - a0 > Math.PI) a1 -= 2 * Math.PI; while (a0 - a1 > Math.PI) a1 += 2 * Math.PI; const punkte: Vektor[] = []; for (let i = 0; i <= stuecke; i += 1) { const a = a0 + ((a1 - a0) * i) / stuecke; punkte.push({ x: mittelpunkt.x + radius * Math.cos(a), y: mittelpunkt.y + radius * Math.sin(a), }); } return punkte; } const ARME = [ { id: 'arm-nord', name: 'Nord', f: { x: 0, y: 1 } }, { id: 'arm-ost', name: 'Ost', f: { x: -1, y: 0 } }, { id: 'arm-sued', name: 'Sued', f: { x: 0, y: -1 } }, { id: 'arm-west', name: 'West', f: { x: 1, y: 0 } }, ] as const; /** `mitHaltlinien` steuert, ob die Stroeme ihrer Zufahrt zugeordnet sind. */ function knotenpunkt(mitHaltlinien: boolean): Lageplan { const haltlinien: Haltlinie[] = []; const linien: Planlinie[] = []; for (const arm of ARME) { const f = arm.f as Vektor; const r = rechts(f); const rr = rechts(r); const aufAchse = plus(MITTE, { v: f, s: -HALTLINIE_VOR }); const haltlinie: Haltlinie = { id: `hl-${arm.id}`, name: arm.name, art: 'zufahrt', armId: arm.id, von: px(aufAchse), bis: px(plus(aufAchse, { v: r, s: HALB })), }; haltlinien.push(haltlinie); const startAussen = plus(aufAchse, { v: r, s: ACHSE_AUSSEN }); const startInnen = plus(aufAchse, { v: r, s: ACHSE_INNEN }); const stroeme: readonly { movement: Movement; startT: number; punkte: readonly Vektor[] }[] = [ { movement: 'geradeaus', startT: ACHSE_AUSSEN / HALB, punkte: [ startAussen, plus(MITTE, { v: f, s: FURT_VOR }, { v: r, s: ACHSE_AUSSEN }), plus(MITTE, { v: f, s: AUSLAUF }, { v: r, s: ACHSE_AUSSEN }), ], }, { movement: 'rechts', startT: ACHSE_AUSSEN / HALB, punkte: [ startAussen, ...bogen( plus( MITTE, { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, { v: r, s: ACHSE_AUSSEN + R_RECHTS }, ), plus(MITTE, { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, { v: r, s: ACHSE_AUSSEN }), plus(MITTE, { v: r, s: ACHSE_AUSSEN + R_RECHTS }, { v: rr, s: ACHSE_AUSSEN }), ), plus(MITTE, { v: r, s: AUSLAUF }, { v: rr, s: ACHSE_AUSSEN }), ], }, { movement: 'links', startT: ACHSE_INNEN / HALB, punkte: [ startInnen, ...bogen( plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN - R_LINKS }), plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN }), plus(MITTE, { v: f, s: ACHSE_INNEN }, { v: r, s: ACHSE_INNEN - R_LINKS }), ), plus(MITTE, { v: r, s: -AUSLAUF }, { v: rr, s: -ACHSE_INNEN }), ], }, ]; for (const strom of stroeme) { linien.push({ id: `${arm.id}-${strom.movement}`, name: `${arm.name} ${strom.movement}`, mode: 'kfz', movement: strom.movement, breiteMeter: REGELBREITE.kfz, punkte: strom.punkte.map(px), haltlinieId: mitHaltlinien ? haltlinie.id : null, startT: strom.startT, signalGroupId: null, }); } const furtAchse = plus(MITTE, { v: f, s: -FURT_VOR }); const bordA = plus(furtAchse, { v: r, s: HALB }); const furtHalt: Haltlinie = { id: `hl-furt-${arm.id}`, name: `Furt ${arm.name}`, art: 'querung', armId: arm.id, von: px(plus(bordA, { v: f, s: -2 })), bis: px(plus(bordA, { v: f, s: 2 })), }; haltlinien.push(furtHalt); linien.push({ id: `${arm.id}-furt`, name: `Furt ${arm.name}`, mode: 'fuss', movement: 'querung', breiteMeter: REGELBREITE.fuss, punkte: [px(bordA), px(plus(furtAchse, { v: r, s: -HALB }))], haltlinieId: mitHaltlinien ? furtHalt.id : null, startT: 0.5, signalGroupId: null, }); } return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: KANTE * PX, hoehe: KANTE * PX, herkunft: 'Pruefstueck', geladenAm: FIXED_DATE.toISOString(), }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: KANTE * PX, y: 0 }, laengeMeter: KANTE, herkunft: 'georeferenziert', }, haltlinien, linien, }; } function projektMitArmen(): Project { const leer = createEmptyProject('Vierarmig', FIXED_DATE); return { ...leer, intersection: { ...leer.intersection, arms: ARME.map((a) => ({ id: a.id, name: a.name, direction: a.name, lanes: 2, vZul: 50 })), }, }; } const zufahrt = (id: string): string => id.split('-')[1] ?? ''; const istFurt = (id: string): boolean => id.endsWith('-furt'); describe('Vierarmiger Knotenpunkt, vollstaendig', () => { const mit = leiteAb(projektMitArmen(), knotenpunkt(true)); const ohne = leiteAb(projektMitArmen(), knotenpunkt(false)); const kfzPaareDerselbenZufahrt = (a: typeof mit) => a.vertraeglichkeit.filter( (v) => zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && !istFurt(v.aLinieId) && !istFurt(v.bLinieId), ); it('zeichnet sechzehn Stroeme an acht Haltlinien', () => { const plan = knotenpunkt(true); expect(plan.linien).toHaveLength(16); expect(plan.haltlinien).toHaveLength(8); }); it('meldet keinen einzigen Konflikt innerhalb einer Zufahrt', () => { const paare = kfzPaareDerselbenZufahrt(mit); expect(paare).toHaveLength(12); expect(paare.filter((v) => v.feindlich)).toHaveLength(0); }); it('meldet ohne Haltlinien Scheinkonflikte innerhalb der Zufahrten', () => { // Der Zustand vor der Aenderung, und der jedes uebernommenen Projekts. // Er wird festgehalten, damit der Gewinn messbar bleibt: Ohne Zuordnung // gelten die gemeinsamen Anfangspunkte als Kreuzungen. const falsch = kfzPaareDerselbenZufahrt(ohne).filter((v) => v.feindlich); expect(falsch.length).toBeGreaterThan(0); // Mit Haltlinien ist keiner davon uebrig. expect(kfzPaareDerselbenZufahrt(mit).filter((v) => v.feindlich)).toHaveLength(0); }); it('haelt jede Furt gegenueber den Stroemen ihres eigenen Arms feindlich', () => { // Die Furt gehoert zum Arm, nicht zu dessen Zufahrt. Wuerde die Ausnahme // hier greifen, bekaeme der Fussgaenger gleichzeitig Gruen mit dem // Fahrzeugstrom, der ihn quert. const paare = mit.vertraeglichkeit.filter( (v) => zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && (istFurt(v.aLinieId) || istFurt(v.bLinieId)), ); expect(paare).toHaveLength(12); expect(paare.every((v) => v.feindlich)).toBe(true); }); it('verliert keinen Konflikt zwischen verschiedenen Zufahrten', () => { const echt = (a: typeof mit) => a.vertraeglichkeit .filter((v) => v.feindlich && zufahrt(v.aLinieId) !== zufahrt(v.bLinieId)) .map((v) => `${v.aLinieId}|${v.bLinieId}`) .sort(); expect(echt(mit)).toEqual(echt(ohne)); expect(echt(mit).length).toBeGreaterThan(25); }); it('erkennt geradeaus und rechts als gemeinsamen Fahrstreifen', () => { // Beide beginnen auf demselben aeusseren Fahrstreifen. Sie sind // vertraeglich, muessen aber denselben Signalgeber bekommen. const paar = mit.vertraeglichkeit.find( (v) => v.aLinieId === 'arm-nord-geradeaus' && v.bLinieId === 'arm-nord-rechts', ); expect(paar?.feindlich).toBe(false); expect(paar?.grund).toBe('auffaecherung'); }); it('haelt die gegenueberliegenden Linksabbieger auseinander', () => { // Sie fahren hintereinander um die Knotenpunktmitte herum. Als Eckzug // gezeichnet schnitten ihre Sehnen sich - der Bogen loest das. const paar = mit.vertraeglichkeit.find( (v) => v.aLinieId === 'arm-nord-links' && v.bLinieId === 'arm-sued-links', ); expect(paar?.feindlich).toBe(false); }); it('rechnet Raeum- und Einfahrweg nachpruefbar', () => { // Nord geradeaus liegt bei x = 39,75 und beginnt bei y = 33. // West geradeaus liegt bei y = 50,25 und beginnt bei x = 33. // Schnittpunkt (39,75 | 50,25): 17,25 m entlang Nord, 6,75 m entlang West. // Raeumweg = 17,25 + 3,25/2 = 18,875 // Einfahrweg = 6,75 - 3,25/2 = 5,125 const weg = mit.wege.find( (w) => w.vonLinieId === 'arm-nord-geradeaus' && w.nachLinieId === 'arm-west-geradeaus', ); expect(weg?.raeumweg).toBeCloseTo(18.88, 2); expect(weg?.einfahrweg).toBeCloseTo(5.13, 2); expect(weg?.winkelGrad).toBeCloseTo(90, 1); }); it('traegt den Plan bis zum ausgabefaehigen Signalzeitenplan', () => { // Die ganze Kette: buendeln, uebernehmen, Phasen bilden, pruefen. let p = projektMitArmen(); p = { ...p, lageplan: knotenpunkt(true) }; const gruppeVon = new Map(); const gruppen = []; for (const arm of ARME) { // Ein Signalgeber fuer geradeaus UND rechts - sie teilen den Fahrstreifen. for (const [suffix, linien] of [ ['', [`${arm.id}-geradeaus`, `${arm.id}-rechts`]], ['-links', [`${arm.id}-links`]], ['-furt', [`${arm.id}-furt`]], ] as const) { const id = `sg-${arm.id}${suffix}`; const istFuss = suffix === '-furt'; gruppen.push({ id, name: `${istFuss ? 'F' : 'K'} ${arm.name}${suffix === '-links' ? ' links' : ''}`, mode: istFuss ? ('fuss' as const) : ('kfz' as const), movement: istFuss ? ('querung' as const) : suffix === '-links' ? ('links' as const) : ('geradeaus' as const), armId: arm.id, vZul: 50, lanes: 1, // Fussgaenger haben keine Fahrzeuglaenge - dafuer steht 'keine'. vehicleClass: istFuss ? ('keine' as const) : ('pkw' as const), minGreenOverride: null, maxGreenOverride: null, reducedMobility: false, color: '#2f6fd0', }); for (const l of linien) gruppeVon.set(l, id); } } p = { ...p, signalGroups: gruppen, lageplan: { ...p.lageplan, linien: p.lageplan.linien.map((l) => ({ ...l, signalGroupId: gruppeVon.get(l.id) ?? null, })), }, }; const abgeleitet = leiteAb(p, p.lageplan); p = uebernimmWege(p, p.lageplan, abgeleitet, abgeleitet.wege.map(wegSchluessel)).project; // Kein Selbstkonflikt, obwohl zwei Stroeme je Gruppe gebuendelt sind. expect(p.conflicts.filter((c) => c.fromId === c.toId)).toHaveLength(0); // Und jede Gruppenbeziehung genau einmal, nicht je Strompaar einmal. const paare = p.conflicts.map((c) => `${c.fromId}|${c.toId}`); expect(new Set(paare).size).toBe(paare.length); const phasen = [ { id: 'ph-1', gruppen: ['sg-arm-nord', 'sg-arm-sued'] }, { id: 'ph-2', gruppen: ['sg-arm-nord-links', 'sg-arm-sued-links'] }, { id: 'ph-3', gruppen: ['sg-arm-ost', 'sg-arm-west'] }, { id: 'ph-4', gruppen: ['sg-arm-ost-links', 'sg-arm-west-links'] }, { id: 'ph-5', gruppen: ARME.map((a) => `sg-${a.id}-furt`) }, ].map((ph) => ({ id: ph.id, name: ph.id, signalGroupIds: ph.gruppen, manualGreen: null, })); p = { ...p, phases: phasen, program: { ...p.program, phaseOrder: phasen.map((ph) => ph.id) }, demands: p.signalGroups.map((g) => ({ signalGroupId: g.id, // Massvolle Nachfrage: Der Fall prueft die Geometrie und die Kette bis // zum Signalzeitenplan, nicht die Leistungsfaehigkeit. Bei fuenf Phasen // und 5 % Schwerverkehr passt sie in eine Umlaufzeit unter 120 s. volume: g.mode === 'fuss' ? 120 : 160, heavyVehicleShare: g.mode === 'fuss' ? 0 : 0.05, })), }; const plan = buildSignalPlan(p); const bericht = validateProject(p, plan, FIXED_DATE); // Ueber die Meldungen und nicht ueber die Anzahl: Schlaegt der Fall fehl, // steht der Grund im Fehlertext, statt dass nur eine Zahl nicht stimmt. expect(bericht.findings.filter((f) => f.severity === 'fehler').map((f) => f.message)).toEqual( [], ); expect(bericht.exportBlocked).toBe(false); expect(plan.cycleTime).toBeGreaterThan(30); expect(plan.cycleTime).toBeLessThanOrEqual(120); expect(plan.feasible).toBe(true); }); });