lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain vierarmig.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { leiteAb, uebernimmWege, wegSchluessel } from '@/domain/geometrie/ableitung'; |
| 3 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 4 | import type { Haltlinie, Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 5 | import { createEmptyProject } from '@/domain/model/factory'; |
| 6 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 7 | import { validateProject } from '@/domain/validation/engine'; |
| 8 | import type { Movement } from '@/domain/rilsa/types'; |
| 9 | import type { Project } from '@/domain/model/project'; |
| 10 | |
| 11 | /** |
| 12 | * Vollstaendiger vierarmiger Knotenpunkt - die Gesamtkontrolle. |
| 13 | * |
| 14 | * Vier Zufahrten mit je geradeaus, links und rechts, dazu vier |
| 15 | * Fussgaengerfurten: sechzehn Stroeme an acht Haltlinien. Das ist der Fall, |
| 16 | * den die Aenderung tragen muss, und er wird hier vollstaendig durchgerechnet - |
| 17 | * von der Zeichnung bis zum ausgabefaehigen Signalzeitenplan. |
| 18 | * |
| 19 | * Die Geometrie steht als Vorschrift, nicht als Zahlenliste: Jede Zufahrt |
| 20 | * entsteht aus derselben Formel, nur gedreht. Dadurch laesst sich jede Zahl von |
| 21 | * Hand nachrechnen - und der Fall bleibt lesbar. |
| 22 | * |
| 23 | * MASSE: Rechtsverkehr, Fahrbahn 14 m, Fahrstreifen 3,5 m, Haltlinie 12 m und |
| 24 | * Furt 9 m vor der Knotenpunktmitte. 1 Bildpunkt = 0,1 m. |
| 25 | */ |
| 26 | |
| 27 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 28 | |
| 29 | const KANTE = 90; |
| 30 | const PX = 10; |
| 31 | const MITTE = { x: 45, y: 45 }; |
| 32 | const HALB = 7; |
| 33 | const ACHSE_INNEN = 1.75; |
| 34 | const ACHSE_AUSSEN = 5.25; |
| 35 | const R_LINKS = 12; |
| 36 | const R_RECHTS = 6; |
| 37 | const HALTLINIE_VOR = 12; |
| 38 | const FURT_VOR = 9; |
| 39 | const AUSLAUF = 40; |
| 40 | |
| 41 | type Vektor = { x: number; y: number }; |
| 42 | |
| 43 | /** Rechts des Fahrers - bei y nach unten ist das (-fy, fx). */ |
| 44 | const rechts = (v: Vektor): Vektor => ({ x: -v.y, y: v.x }); |
| 45 | |
| 46 | const plus = (a: Vektor, ...teile: readonly { v: Vektor; s: number }[]): Vektor => |
| 47 | teile.reduce((p, t) => ({ x: p.x + t.v.x * t.s, y: p.y + t.v.y * t.s }), a); |
| 48 | |
| 49 | const px = (p: Vektor): Vektor => ({ x: p.x * PX, y: p.y * PX }); |
| 50 | |
| 51 | /** Kreisbogen - Abbiegevorgaenge als Eckzug erzeugen Scheinkonflikte. */ |
| 52 | function bogen(mittelpunkt: Vektor, von: Vektor, bis: Vektor, stuecke = 8): Vektor[] { |
| 53 | const radius = Math.hypot(von.x - mittelpunkt.x, von.y - mittelpunkt.y); |
| 54 | const a0 = Math.atan2(von.y - mittelpunkt.y, von.x - mittelpunkt.x); |
| 55 | let a1 = Math.atan2(bis.y - mittelpunkt.y, bis.x - mittelpunkt.x); |
| 56 | while (a1 - a0 > Math.PI) a1 -= 2 * Math.PI; |
| 57 | while (a0 - a1 > Math.PI) a1 += 2 * Math.PI; |
| 58 | |
| 59 | const punkte: Vektor[] = []; |
| 60 | for (let i = 0; i <= stuecke; i += 1) { |
| 61 | const a = a0 + ((a1 - a0) * i) / stuecke; |
| 62 | punkte.push({ |
| 63 | x: mittelpunkt.x + radius * Math.cos(a), |
| 64 | y: mittelpunkt.y + radius * Math.sin(a), |
| 65 | }); |
| 66 | } |
| 67 | return punkte; |
| 68 | } |
| 69 | |
| 70 | const ARME = [ |
| 71 | { id: 'arm-nord', name: 'Nord', f: { x: 0, y: 1 } }, |
| 72 | { id: 'arm-ost', name: 'Ost', f: { x: -1, y: 0 } }, |
| 73 | { id: 'arm-sued', name: 'Sued', f: { x: 0, y: -1 } }, |
| 74 | { id: 'arm-west', name: 'West', f: { x: 1, y: 0 } }, |
| 75 | ] as const; |
| 76 | |
| 77 | /** `mitHaltlinien` steuert, ob die Stroeme ihrer Zufahrt zugeordnet sind. */ |
| 78 | function knotenpunkt(mitHaltlinien: boolean): Lageplan { |
| 79 | const haltlinien: Haltlinie[] = []; |
| 80 | const linien: Planlinie[] = []; |
| 81 | |
| 82 | for (const arm of ARME) { |
| 83 | const f = arm.f as Vektor; |
| 84 | const r = rechts(f); |
| 85 | const rr = rechts(r); |
| 86 | |
| 87 | const aufAchse = plus(MITTE, { v: f, s: -HALTLINIE_VOR }); |
| 88 | const haltlinie: Haltlinie = { |
| 89 | id: `hl-${arm.id}`, |
| 90 | name: arm.name, |
| 91 | art: 'zufahrt', |
| 92 | armId: arm.id, |
| 93 | von: px(aufAchse), |
| 94 | bis: px(plus(aufAchse, { v: r, s: HALB })), |
| 95 | }; |
| 96 | haltlinien.push(haltlinie); |
| 97 | |
| 98 | const startAussen = plus(aufAchse, { v: r, s: ACHSE_AUSSEN }); |
| 99 | const startInnen = plus(aufAchse, { v: r, s: ACHSE_INNEN }); |
| 100 | |
| 101 | const stroeme: readonly { movement: Movement; startT: number; punkte: readonly Vektor[] }[] = [ |
| 102 | { |
| 103 | movement: 'geradeaus', |
| 104 | startT: ACHSE_AUSSEN / HALB, |
| 105 | punkte: [ |
| 106 | startAussen, |
| 107 | plus(MITTE, { v: f, s: FURT_VOR }, { v: r, s: ACHSE_AUSSEN }), |
| 108 | plus(MITTE, { v: f, s: AUSLAUF }, { v: r, s: ACHSE_AUSSEN }), |
| 109 | ], |
| 110 | }, |
| 111 | { |
| 112 | movement: 'rechts', |
| 113 | startT: ACHSE_AUSSEN / HALB, |
| 114 | punkte: [ |
| 115 | startAussen, |
| 116 | ...bogen( |
| 117 | plus( |
| 118 | MITTE, |
| 119 | { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, |
| 120 | { v: r, s: ACHSE_AUSSEN + R_RECHTS }, |
| 121 | ), |
| 122 | plus(MITTE, { v: f, s: -(ACHSE_AUSSEN + R_RECHTS) }, { v: r, s: ACHSE_AUSSEN }), |
| 123 | plus(MITTE, { v: r, s: ACHSE_AUSSEN + R_RECHTS }, { v: rr, s: ACHSE_AUSSEN }), |
| 124 | ), |
| 125 | plus(MITTE, { v: r, s: AUSLAUF }, { v: rr, s: ACHSE_AUSSEN }), |
| 126 | ], |
| 127 | }, |
| 128 | { |
| 129 | movement: 'links', |
| 130 | startT: ACHSE_INNEN / HALB, |
| 131 | punkte: [ |
| 132 | startInnen, |
| 133 | ...bogen( |
| 134 | plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN - R_LINKS }), |
| 135 | plus(MITTE, { v: f, s: ACHSE_INNEN - R_LINKS }, { v: r, s: ACHSE_INNEN }), |
| 136 | plus(MITTE, { v: f, s: ACHSE_INNEN }, { v: r, s: ACHSE_INNEN - R_LINKS }), |
| 137 | ), |
| 138 | plus(MITTE, { v: r, s: -AUSLAUF }, { v: rr, s: -ACHSE_INNEN }), |
| 139 | ], |
| 140 | }, |
| 141 | ]; |
| 142 | |
| 143 | for (const strom of stroeme) { |
| 144 | linien.push({ |
| 145 | id: `${arm.id}-${strom.movement}`, |
| 146 | name: `${arm.name} ${strom.movement}`, |
| 147 | mode: 'kfz', |
| 148 | movement: strom.movement, |
| 149 | breiteMeter: REGELBREITE.kfz, |
| 150 | punkte: strom.punkte.map(px), |
| 151 | haltlinieId: mitHaltlinien ? haltlinie.id : null, |
| 152 | startT: strom.startT, |
| 153 | signalGroupId: null, |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | const furtAchse = plus(MITTE, { v: f, s: -FURT_VOR }); |
| 158 | const bordA = plus(furtAchse, { v: r, s: HALB }); |
| 159 | const furtHalt: Haltlinie = { |
| 160 | id: `hl-furt-${arm.id}`, |
| 161 | name: `Furt ${arm.name}`, |
| 162 | art: 'querung', |
| 163 | armId: arm.id, |
| 164 | von: px(plus(bordA, { v: f, s: -2 })), |
| 165 | bis: px(plus(bordA, { v: f, s: 2 })), |
| 166 | }; |
| 167 | haltlinien.push(furtHalt); |
| 168 | linien.push({ |
| 169 | id: `${arm.id}-furt`, |
| 170 | name: `Furt ${arm.name}`, |
| 171 | mode: 'fuss', |
| 172 | movement: 'querung', |
| 173 | breiteMeter: REGELBREITE.fuss, |
| 174 | punkte: [px(bordA), px(plus(furtAchse, { v: r, s: -HALB }))], |
| 175 | haltlinieId: mitHaltlinien ? furtHalt.id : null, |
| 176 | startT: 0.5, |
| 177 | signalGroupId: null, |
| 178 | }); |
| 179 | } |
| 180 | |
| 181 | return { |
| 182 | arbeitsbereiche: [], |
| 183 | signalgeber: [], |
| 184 | bild: { |
| 185 | datenUrl: 'data:image/png;base64,AAAA', |
| 186 | breite: KANTE * PX, |
| 187 | hoehe: KANTE * PX, |
| 188 | herkunft: 'Pruefstueck', |
| 189 | geladenAm: FIXED_DATE.toISOString(), |
| 190 | }, |
| 191 | kalibrierung: { |
| 192 | von: { x: 0, y: 0 }, |
| 193 | bis: { x: KANTE * PX, y: 0 }, |
| 194 | laengeMeter: KANTE, |
| 195 | herkunft: 'georeferenziert', |
| 196 | }, |
| 197 | haltlinien, |
| 198 | linien, |
| 199 | }; |
| 200 | } |
| 201 | |
| 202 | function projektMitArmen(): Project { |
| 203 | const leer = createEmptyProject('Vierarmig', FIXED_DATE); |
| 204 | return { |
| 205 | ...leer, |
| 206 | intersection: { |
| 207 | ...leer.intersection, |
| 208 | arms: ARME.map((a) => ({ id: a.id, name: a.name, direction: a.name, lanes: 2, vZul: 50 })), |
| 209 | }, |
| 210 | }; |
| 211 | } |
| 212 | |
| 213 | const zufahrt = (id: string): string => id.split('-')[1] ?? ''; |
| 214 | const istFurt = (id: string): boolean => id.endsWith('-furt'); |
| 215 | |
| 216 | describe('Vierarmiger Knotenpunkt, vollstaendig', () => { |
| 217 | const mit = leiteAb(projektMitArmen(), knotenpunkt(true)); |
| 218 | const ohne = leiteAb(projektMitArmen(), knotenpunkt(false)); |
| 219 | |
| 220 | const kfzPaareDerselbenZufahrt = (a: typeof mit) => |
| 221 | a.vertraeglichkeit.filter( |
| 222 | (v) => |
| 223 | zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && !istFurt(v.aLinieId) && !istFurt(v.bLinieId), |
| 224 | ); |
| 225 | |
| 226 | it('zeichnet sechzehn Stroeme an acht Haltlinien', () => { |
| 227 | const plan = knotenpunkt(true); |
| 228 | expect(plan.linien).toHaveLength(16); |
| 229 | expect(plan.haltlinien).toHaveLength(8); |
| 230 | }); |
| 231 | |
| 232 | it('meldet keinen einzigen Konflikt innerhalb einer Zufahrt', () => { |
| 233 | const paare = kfzPaareDerselbenZufahrt(mit); |
| 234 | expect(paare).toHaveLength(12); |
| 235 | expect(paare.filter((v) => v.feindlich)).toHaveLength(0); |
| 236 | }); |
| 237 | |
| 238 | it('meldet ohne Haltlinien Scheinkonflikte innerhalb der Zufahrten', () => { |
| 239 | // Der Zustand vor der Aenderung, und der jedes uebernommenen Projekts. |
| 240 | // Er wird festgehalten, damit der Gewinn messbar bleibt: Ohne Zuordnung |
| 241 | // gelten die gemeinsamen Anfangspunkte als Kreuzungen. |
| 242 | const falsch = kfzPaareDerselbenZufahrt(ohne).filter((v) => v.feindlich); |
| 243 | expect(falsch.length).toBeGreaterThan(0); |
| 244 | // Mit Haltlinien ist keiner davon uebrig. |
| 245 | expect(kfzPaareDerselbenZufahrt(mit).filter((v) => v.feindlich)).toHaveLength(0); |
| 246 | }); |
| 247 | |
| 248 | it('haelt jede Furt gegenueber den Stroemen ihres eigenen Arms feindlich', () => { |
| 249 | // Die Furt gehoert zum Arm, nicht zu dessen Zufahrt. Wuerde die Ausnahme |
| 250 | // hier greifen, bekaeme der Fussgaenger gleichzeitig Gruen mit dem |
| 251 | // Fahrzeugstrom, der ihn quert. |
| 252 | const paare = mit.vertraeglichkeit.filter( |
| 253 | (v) => |
| 254 | zufahrt(v.aLinieId) === zufahrt(v.bLinieId) && (istFurt(v.aLinieId) || istFurt(v.bLinieId)), |
| 255 | ); |
| 256 | expect(paare).toHaveLength(12); |
| 257 | expect(paare.every((v) => v.feindlich)).toBe(true); |
| 258 | }); |
| 259 | |
| 260 | it('verliert keinen Konflikt zwischen verschiedenen Zufahrten', () => { |
| 261 | const echt = (a: typeof mit) => |
| 262 | a.vertraeglichkeit |
| 263 | .filter((v) => v.feindlich && zufahrt(v.aLinieId) !== zufahrt(v.bLinieId)) |
| 264 | .map((v) => `${v.aLinieId}|${v.bLinieId}`) |
| 265 | .sort(); |
| 266 | expect(echt(mit)).toEqual(echt(ohne)); |
| 267 | expect(echt(mit).length).toBeGreaterThan(25); |
| 268 | }); |
| 269 | |
| 270 | it('erkennt geradeaus und rechts als gemeinsamen Fahrstreifen', () => { |
| 271 | // Beide beginnen auf demselben aeusseren Fahrstreifen. Sie sind |
| 272 | // vertraeglich, muessen aber denselben Signalgeber bekommen. |
| 273 | const paar = mit.vertraeglichkeit.find( |
| 274 | (v) => v.aLinieId === 'arm-nord-geradeaus' && v.bLinieId === 'arm-nord-rechts', |
| 275 | ); |
| 276 | expect(paar?.feindlich).toBe(false); |
| 277 | expect(paar?.grund).toBe('auffaecherung'); |
| 278 | }); |
| 279 | |
| 280 | it('haelt die gegenueberliegenden Linksabbieger auseinander', () => { |
| 281 | // Sie fahren hintereinander um die Knotenpunktmitte herum. Als Eckzug |
| 282 | // gezeichnet schnitten ihre Sehnen sich - der Bogen loest das. |
| 283 | const paar = mit.vertraeglichkeit.find( |
| 284 | (v) => v.aLinieId === 'arm-nord-links' && v.bLinieId === 'arm-sued-links', |
| 285 | ); |
| 286 | expect(paar?.feindlich).toBe(false); |
| 287 | }); |
| 288 | |
| 289 | it('rechnet Raeum- und Einfahrweg nachpruefbar', () => { |
| 290 | // Nord geradeaus liegt bei x = 39,75 und beginnt bei y = 33. |
| 291 | // West geradeaus liegt bei y = 50,25 und beginnt bei x = 33. |
| 292 | // Schnittpunkt (39,75 | 50,25): 17,25 m entlang Nord, 6,75 m entlang West. |
| 293 | // Raeumweg = 17,25 + 3,25/2 = 18,875 |
| 294 | // Einfahrweg = 6,75 - 3,25/2 = 5,125 |
| 295 | const weg = mit.wege.find( |
| 296 | (w) => w.vonLinieId === 'arm-nord-geradeaus' && w.nachLinieId === 'arm-west-geradeaus', |
| 297 | ); |
| 298 | expect(weg?.raeumweg).toBeCloseTo(18.88, 2); |
| 299 | expect(weg?.einfahrweg).toBeCloseTo(5.13, 2); |
| 300 | expect(weg?.winkelGrad).toBeCloseTo(90, 1); |
| 301 | }); |
| 302 | |
| 303 | it('traegt den Plan bis zum ausgabefaehigen Signalzeitenplan', () => { |
| 304 | // Die ganze Kette: buendeln, uebernehmen, Phasen bilden, pruefen. |
| 305 | let p = projektMitArmen(); |
| 306 | p = { ...p, lageplan: knotenpunkt(true) }; |
| 307 | |
| 308 | const gruppeVon = new Map<string, string>(); |
| 309 | const gruppen = []; |
| 310 | for (const arm of ARME) { |
| 311 | // Ein Signalgeber fuer geradeaus UND rechts - sie teilen den Fahrstreifen. |
| 312 | for (const [suffix, linien] of [ |
| 313 | ['', [`${arm.id}-geradeaus`, `${arm.id}-rechts`]], |
| 314 | ['-links', [`${arm.id}-links`]], |
| 315 | ['-furt', [`${arm.id}-furt`]], |
| 316 | ] as const) { |
| 317 | const id = `sg-${arm.id}${suffix}`; |
| 318 | const istFuss = suffix === '-furt'; |
| 319 | gruppen.push({ |
| 320 | id, |
| 321 | name: `${istFuss ? 'F' : 'K'} ${arm.name}${suffix === '-links' ? ' links' : ''}`, |
| 322 | mode: istFuss ? ('fuss' as const) : ('kfz' as const), |
| 323 | movement: istFuss |
| 324 | ? ('querung' as const) |
| 325 | : suffix === '-links' |
| 326 | ? ('links' as const) |
| 327 | : ('geradeaus' as const), |
| 328 | armId: arm.id, |
| 329 | vZul: 50, |
| 330 | lanes: 1, |
| 331 | // Fussgaenger haben keine Fahrzeuglaenge - dafuer steht 'keine'. |
| 332 | vehicleClass: istFuss ? ('keine' as const) : ('pkw' as const), |
| 333 | minGreenOverride: null, |
| 334 | maxGreenOverride: null, |
| 335 | reducedMobility: false, |
| 336 | color: '#2f6fd0', |
| 337 | }); |
| 338 | for (const l of linien) gruppeVon.set(l, id); |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | p = { |
| 343 | ...p, |
| 344 | signalGroups: gruppen, |
| 345 | lageplan: { |
| 346 | ...p.lageplan, |
| 347 | linien: p.lageplan.linien.map((l) => ({ |
| 348 | ...l, |
| 349 | signalGroupId: gruppeVon.get(l.id) ?? null, |
| 350 | })), |
| 351 | }, |
| 352 | }; |
| 353 | |
| 354 | const abgeleitet = leiteAb(p, p.lageplan); |
| 355 | p = uebernimmWege(p, p.lageplan, abgeleitet, abgeleitet.wege.map(wegSchluessel)).project; |
| 356 | |
| 357 | // Kein Selbstkonflikt, obwohl zwei Stroeme je Gruppe gebuendelt sind. |
| 358 | expect(p.conflicts.filter((c) => c.fromId === c.toId)).toHaveLength(0); |
| 359 | // Und jede Gruppenbeziehung genau einmal, nicht je Strompaar einmal. |
| 360 | const paare = p.conflicts.map((c) => `${c.fromId}|${c.toId}`); |
| 361 | expect(new Set(paare).size).toBe(paare.length); |
| 362 | |
| 363 | const phasen = [ |
| 364 | { id: 'ph-1', gruppen: ['sg-arm-nord', 'sg-arm-sued'] }, |
| 365 | { id: 'ph-2', gruppen: ['sg-arm-nord-links', 'sg-arm-sued-links'] }, |
| 366 | { id: 'ph-3', gruppen: ['sg-arm-ost', 'sg-arm-west'] }, |
| 367 | { id: 'ph-4', gruppen: ['sg-arm-ost-links', 'sg-arm-west-links'] }, |
| 368 | { id: 'ph-5', gruppen: ARME.map((a) => `sg-${a.id}-furt`) }, |
| 369 | ].map((ph) => ({ |
| 370 | id: ph.id, |
| 371 | name: ph.id, |
| 372 | signalGroupIds: ph.gruppen, |
| 373 | manualGreen: null, |
| 374 | })); |
| 375 | |
| 376 | p = { |
| 377 | ...p, |
| 378 | phases: phasen, |
| 379 | program: { ...p.program, phaseOrder: phasen.map((ph) => ph.id) }, |
| 380 | demands: p.signalGroups.map((g) => ({ |
| 381 | signalGroupId: g.id, |
| 382 | // Massvolle Nachfrage: Der Fall prueft die Geometrie und die Kette bis |
| 383 | // zum Signalzeitenplan, nicht die Leistungsfaehigkeit. Bei fuenf Phasen |
| 384 | // und 5 % Schwerverkehr passt sie in eine Umlaufzeit unter 120 s. |
| 385 | volume: g.mode === 'fuss' ? 120 : 160, |
| 386 | heavyVehicleShare: g.mode === 'fuss' ? 0 : 0.05, |
| 387 | })), |
| 388 | }; |
| 389 | |
| 390 | const plan = buildSignalPlan(p); |
| 391 | const bericht = validateProject(p, plan, FIXED_DATE); |
| 392 | |
| 393 | // Ueber die Meldungen und nicht ueber die Anzahl: Schlaegt der Fall fehl, |
| 394 | // steht der Grund im Fehlertext, statt dass nur eine Zahl nicht stimmt. |
| 395 | expect(bericht.findings.filter((f) => f.severity === 'fehler').map((f) => f.message)).toEqual( |
| 396 | [], |
| 397 | ); |
| 398 | expect(bericht.exportBlocked).toBe(false); |
| 399 | expect(plan.cycleTime).toBeGreaterThan(30); |
| 400 | expect(plan.cycleTime).toBeLessThanOrEqual(120); |
| 401 | expect(plan.feasible).toBe(true); |
| 402 | }); |
| 403 | }); |