lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain phaseneinteilung.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { |
| 3 | leiteAb, |
| 4 | uebernimmSignalgruppen, |
| 5 | uebernimmWege, |
| 6 | wegSchluessel, |
| 7 | } from '@/domain/geometrie/ableitung'; |
| 8 | import { REGELBREITE } from '@/domain/geometrie/vermessung'; |
| 9 | import { createEmptyProject, createSignalGroup } from '@/domain/model/factory'; |
| 10 | import { |
| 11 | MAX_PHASENMENGEN, |
| 12 | MAX_VORSCHLAEGE, |
| 13 | maximaleMengen, |
| 14 | sucheEinteilungen, |
| 15 | vertraeglichkeitsbelege, |
| 16 | } from '@/domain/plan/phaseneinteilung'; |
| 17 | import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; |
| 18 | import type { Project } from '@/domain/model/project'; |
| 19 | |
| 20 | /** |
| 21 | * Fassung 5.36.0 - zulaessige Phaseneinteilungen. |
| 22 | * |
| 23 | * DIE BEDINGUNG, UNTER DER DIESER PUNKT UEBERHAUPT GEBAUT WERDEN DURFTE: |
| 24 | * "Ohne positiven Beleg der Vertraeglichkeit jedes Paares darf kein Vorschlag |
| 25 | * erscheinen. Das ist keine Feinheit." Der erste Abschnitt dieser Datei |
| 26 | * bewacht genau das - und zwar in beide Richtungen: kein Vorschlag ohne Beleg, |
| 27 | * und Vorschlaege, sobald er da ist. |
| 28 | * |
| 29 | * Der Grund: Eine Reihung nach kleinster Umlaufzeit bevorzugt wenige Phasen, |
| 30 | * also viele gleichzeitig freigegebene Gruppen. Im Datenbestand sieht |
| 31 | * "geprueft vertraeglich" aber genauso aus wie "noch nicht betrachtet", und |
| 32 | * mit jedem entfallenden Phasenuebergang faellt eine Zwischenzeit weg. |
| 33 | */ |
| 34 | |
| 35 | const STICHTAG = new Date('2026-01-01T00:00:00Z'); |
| 36 | |
| 37 | function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie { |
| 38 | return { |
| 39 | id, |
| 40 | name, |
| 41 | mode: 'kfz', |
| 42 | movement: 'geradeaus', |
| 43 | breiteMeter: REGELBREITE.kfz, |
| 44 | punkte, |
| 45 | haltlinieId: null, |
| 46 | startT: 0.5, |
| 47 | signalGroupId: null, |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Drei Stroeme: K1 und K3 laufen parallel, K2 kreuzt beide. |
| 53 | * |
| 54 | * Damit gibt es genau eine sinnvolle Einteilung - {K1, K3} und {K2} -, und |
| 55 | * jede Aussage dieser Datei laesst sich von Hand nachvollziehen. |
| 56 | */ |
| 57 | function lageplan(): Lageplan { |
| 58 | return { |
| 59 | arbeitsbereiche: [], |
| 60 | signalgeber: [], |
| 61 | haltlinien: [], |
| 62 | bild: { |
| 63 | datenUrl: 'data:image/png;base64,AAAA', |
| 64 | breite: 400, |
| 65 | hoehe: 400, |
| 66 | herkunft: 'Prüfstück', |
| 67 | geladenAm: STICHTAG.toISOString(), |
| 68 | }, |
| 69 | // 100 Bildpunkte sind 50 m. |
| 70 | kalibrierung: { |
| 71 | von: { x: 0, y: 0 }, |
| 72 | bis: { x: 100, y: 0 }, |
| 73 | laengeMeter: 50, |
| 74 | herkunft: 'gemessen', |
| 75 | }, |
| 76 | linien: [ |
| 77 | linie('l-w', 'K1', [ |
| 78 | { x: 0, y: 100 }, |
| 79 | { x: 200, y: 100 }, |
| 80 | ]), |
| 81 | linie('l-s', 'K2', [ |
| 82 | { x: 100, y: 0 }, |
| 83 | { x: 100, y: 200 }, |
| 84 | ]), |
| 85 | linie('l-p', 'K3', [ |
| 86 | { x: 0, y: 160 }, |
| 87 | { x: 200, y: 160 }, |
| 88 | ]), |
| 89 | ], |
| 90 | }; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt. |
| 95 | * |
| 96 | * Der Weg ist derselbe, den die Oberflaeche geht: Signalgruppen aus den |
| 97 | * Fahrlinien uebernehmen, danach die Wege. Ein von Hand gebautes Projekt |
| 98 | * haette den Beleg nicht - und genau darum geht es hier. |
| 99 | */ |
| 100 | function belegtesProjekt(): Project { |
| 101 | const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() }; |
| 102 | const alleLinien = leer.lageplan.linien.map((l) => l.id); |
| 103 | const gruppen = uebernimmSignalgruppen(leer, leer.lageplan, alleLinien); |
| 104 | const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; |
| 105 | const ableitung = leiteAb(mitPlan, mitPlan.lageplan); |
| 106 | const wege = uebernimmWege( |
| 107 | mitPlan, |
| 108 | mitPlan.lageplan, |
| 109 | ableitung, |
| 110 | ableitung.wege.map((w) => wegSchluessel(w)), |
| 111 | STICHTAG, |
| 112 | ); |
| 113 | return wege.project; |
| 114 | } |
| 115 | |
| 116 | describe('Phaseneinteilung - ohne Beleg kein Vorschlag', () => { |
| 117 | it('macht keinen einzigen Vorschlag, solange ein Paar unbelegt ist', () => { |
| 118 | /* |
| 119 | * DER FALL, UM DEN ES GEHT. Zwei Signalgruppen, kein Lageplan, kein |
| 120 | * erfasster Konflikt: Das Programm HÄLT sie für gleichzeitig freigebbar – |
| 121 | * der Signalzeitenplan tut es jedenfalls. Ein Vorschlag würde daraus eine |
| 122 | * Empfehlung machen, und niemand hätte je geprüft, ob sich die beiden |
| 123 | * Ströme vertragen. |
| 124 | */ |
| 125 | const roh = createEmptyProject('ohne Plan', STICHTAG); |
| 126 | const ohnePlan: Project = { |
| 127 | ...roh, |
| 128 | signalGroups: [{ ...leereGruppe('sg-a', 'K1') }, { ...leereGruppe('sg-b', 'K2') }], |
| 129 | }; |
| 130 | const suche = sucheEinteilungen(ohnePlan); |
| 131 | expect(suche.hindernis).toBe('ohne-beleg'); |
| 132 | expect(suche.vorschlaege).toEqual([]); |
| 133 | expect(suche.beleg.ohneBeleg).toHaveLength(1); |
| 134 | expect(suche.beleg.ohneBeleg[0]?.grund).toContain('nicht belegt'); |
| 135 | }); |
| 136 | |
| 137 | it('nennt die unbelegten Paare beim Namen', () => { |
| 138 | // Ein leeres Ergebnis ohne Grund waere das Schlimmste: Der Bearbeiter |
| 139 | // hielte es fuer "es gibt keine Einteilung". |
| 140 | const projekt = belegtesProjekt(); |
| 141 | const zusatz: Project = { |
| 142 | ...projekt, |
| 143 | signalGroups: [...projekt.signalGroups, leereGruppe('sg-neu', 'K9')], |
| 144 | }; |
| 145 | const suche = sucheEinteilungen(zusatz); |
| 146 | expect(suche.hindernis).toBe('ohne-beleg'); |
| 147 | const namen = suche.beleg.ohneBeleg.map((p) => `${p.aName}/${p.bName}`); |
| 148 | expect(namen.every((n) => n.includes('K9'))).toBe(true); |
| 149 | expect(namen.length).toBe(3); |
| 150 | }); |
| 151 | |
| 152 | it('nimmt den Beleg aus dem Lageplan und schlägt dann vor', () => { |
| 153 | const suche = sucheEinteilungen(belegtesProjekt()); |
| 154 | expect(suche.hindernis).toBeNull(); |
| 155 | expect(suche.beleg.vollstaendig).toBe(true); |
| 156 | expect(suche.vorschlaege.length).toBeGreaterThan(0); |
| 157 | // Der Beleg sagt, woher er kommt - und nennt die geprüften Strompaare. |
| 158 | const belegt = suche.beleg.paare.filter((p) => p.art === 'belegt'); |
| 159 | expect(belegt.length).toBeGreaterThan(0); |
| 160 | expect(belegt[0]?.grund).toContain('Im Lageplan geprüft'); |
| 161 | }); |
| 162 | |
| 163 | it('belegt kein Paar, dessen Fahrlinien sich kreuzen - auch ohne erfassten Konflikt', () => { |
| 164 | /* |
| 165 | * DER GEFAEHRLICHSTE FALL. Wer eine Konfliktbeziehung von Hand aufhebt, |
| 166 | * ohne die Zeichnung zu aendern, hinterlaesst ein Paar OHNE erfassten |
| 167 | * Konflikt, dessen Fahrlinien sich kreuzen. Wuerde die Zeichnung hier |
| 168 | * ignoriert, gaelte das Paar als "belegt vertraeglich" - und der Vorschlag |
| 169 | * gaebe zwei kreuzenden Stroemen gleichzeitig Gruen. |
| 170 | */ |
| 171 | const projekt = belegtesProjekt(); |
| 172 | const ohneKonflikte: Project = { ...projekt, conflicts: [] }; |
| 173 | const stand = vertraeglichkeitsbelege(ohneKonflikte); |
| 174 | |
| 175 | expect(stand.paare.some((p) => p.art === 'feindlich')).toBe(false); |
| 176 | const kreuzend = stand.paare.filter((p) => p.art === 'ohne-beleg'); |
| 177 | expect(kreuzend.length, 'die kreuzenden Paare dürfen nicht als belegt gelten').toBe(2); |
| 178 | expect(kreuzend[0]?.grund).toContain('uneindeutig'); |
| 179 | expect(sucheEinteilungen(ohneKonflikte).hindernis).toBe('ohne-beleg'); |
| 180 | }); |
| 181 | |
| 182 | it('führt eine erfasste feindliche Beziehung als Aussage, nicht als Lücke', () => { |
| 183 | const suche = sucheEinteilungen(belegtesProjekt()); |
| 184 | const feindlich = suche.beleg.paare.filter((p) => p.art === 'feindlich'); |
| 185 | expect(feindlich.length).toBeGreaterThan(0); |
| 186 | expect(feindlich[0]?.grund).toContain('erfasst'); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | describe('Phaseneinteilung - was vorgeschlagen wird', () => { |
| 191 | it('fasst nur paarweise verträgliche Gruppen in eine Phase', () => { |
| 192 | /* |
| 193 | * DIE ZWEITE SICHERHEITSZUSAGE. Eine Phase ist eine Menge gleichzeitig |
| 194 | * freigegebener Gruppen; steht darin ein feindliches Paar, bekommen zwei |
| 195 | * kreuzende Ströme zugleich Grün. |
| 196 | */ |
| 197 | const projekt = belegtesProjekt(); |
| 198 | const feindlich = new Set(projekt.conflicts.map((c) => [c.fromId, c.toId].sort().join('|'))); |
| 199 | for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { |
| 200 | for (const phase of vorschlag.phasen) { |
| 201 | for (let i = 0; i < phase.length; i += 1) { |
| 202 | for (let j = i + 1; j < phase.length; j += 1) { |
| 203 | expect(feindlich.has([phase[i]!, phase[j]!].sort().join('|'))).toBe(false); |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | }); |
| 209 | |
| 210 | it('fasst jede Phase so weit wie möglich zusammen', () => { |
| 211 | /* |
| 212 | * Eine Phase, der sich eine weitere vertraegliche Gruppe hinzufuegen |
| 213 | * liesse, verschenkt Freigabezeit - sie waere nie die Antwort auf "welche |
| 214 | * Einteilung ergibt die kuerzeste Umlaufzeit". Solche Vorschlaege |
| 215 | * blaehten die Liste auf und stuenden mit schlechteren Zahlen darin. |
| 216 | */ |
| 217 | const projekt = belegtesProjekt(); |
| 218 | const belegt = new Set( |
| 219 | vertraeglichkeitsbelege(projekt) |
| 220 | .paare.filter((p) => p.art === 'belegt') |
| 221 | .map((p) => [p.aId, p.bId].sort().join('|')), |
| 222 | ); |
| 223 | const vertraeglich = (a: string, b: string): boolean => belegt.has([a, b].sort().join('|')); |
| 224 | |
| 225 | for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { |
| 226 | for (const phase of vorschlag.phasen) { |
| 227 | const erweiterbar = projekt.signalGroups |
| 228 | .map((g) => g.id) |
| 229 | .filter((id) => !phase.includes(id) && phase.every((p) => vertraeglich(p, id))); |
| 230 | expect(erweiterbar, `Phase ${phase.join('+')} ließe sich erweitern`).toEqual([]); |
| 231 | } |
| 232 | } |
| 233 | }); |
| 234 | |
| 235 | it('gibt jeder Signalgruppe wenigstens eine Phase', () => { |
| 236 | // Eine Gruppe ohne Phase bekommt nie Freigabe - ein Vorschlag, der sie |
| 237 | // auslaesst, waere kein Signalzeitenplan. |
| 238 | const projekt = belegtesProjekt(); |
| 239 | const alle = new Set(projekt.signalGroups.map((g) => g.id)); |
| 240 | for (const vorschlag of sucheEinteilungen(projekt).vorschlaege) { |
| 241 | const enthalten = new Set(vorschlag.phasen.flat()); |
| 242 | expect(enthalten).toEqual(alle); |
| 243 | } |
| 244 | }); |
| 245 | |
| 246 | it('reiht nach Umlaufzeit', () => { |
| 247 | /* |
| 248 | * MIT VIER GRUPPEN, damit es ueberhaupt etwas zu reihen gibt: Bei drei |
| 249 | * Stroemen bleibt eine einzige Einteilung uebrig, und eine Liste mit einem |
| 250 | * Eintrag ist in jeder Reihenfolge sortiert - ein Fall, der das nicht |
| 251 | * beachtet, bewacht nichts. |
| 252 | */ |
| 253 | const vorschlaege = sucheEinteilungen(vierStroeme()).vorschlaege; |
| 254 | expect(vorschlaege.length, 'zum Reihen braucht es mehr als einen Vorschlag').toBeGreaterThan(1); |
| 255 | const zeiten = vorschlaege.map((v) => v.umlaufzeit); |
| 256 | expect(new Set(zeiten).size, 'und mehr als eine Umlaufzeit').toBeGreaterThan(1); |
| 257 | expect([...zeiten].sort((a, b) => a - b)).toEqual(zeiten); |
| 258 | for (const zeit of zeiten) expect(zeit).toBeGreaterThan(0); |
| 259 | }); |
| 260 | |
| 261 | it('erkennt die heutige Einteilung wieder', () => { |
| 262 | /* |
| 263 | * Ohne diesen Vermerk sucht der Bearbeiter in der Liste nach dem, was er |
| 264 | * schon hat - und findet es nicht, weil die Phasen anders heissen. |
| 265 | */ |
| 266 | const projekt = belegtesProjekt(); |
| 267 | const suche = sucheEinteilungen(projekt); |
| 268 | const erste = suche.vorschlaege[0]; |
| 269 | expect(erste).toBeDefined(); |
| 270 | |
| 271 | const uebernommen: Project = { |
| 272 | ...projekt, |
| 273 | phases: erste!.phasen.map((gruppen, index) => ({ |
| 274 | id: `p-${String(index)}`, |
| 275 | name: `Phase ${String(index + 1)}`, |
| 276 | signalGroupIds: [...gruppen], |
| 277 | manualGreen: null, |
| 278 | })), |
| 279 | }; |
| 280 | const danach = sucheEinteilungen(uebernommen); |
| 281 | expect(danach.vorschlaege.some((v) => v.heutige)).toBe(true); |
| 282 | }); |
| 283 | |
| 284 | it('bleibt unter der Höchstzahl der Vorschläge', () => { |
| 285 | expect(sucheEinteilungen(belegtesProjekt()).vorschlaege.length).toBeLessThanOrEqual( |
| 286 | MAX_VORSCHLAEGE, |
| 287 | ); |
| 288 | }); |
| 289 | |
| 290 | it('sagt bei einer einzigen Gruppe, dass es nichts einzuteilen gibt', () => { |
| 291 | const roh = createEmptyProject('eine', STICHTAG); |
| 292 | const eine: Project = { ...roh, signalGroups: [leereGruppe('sg-a', 'K1')] }; |
| 293 | expect(sucheEinteilungen(eine).hindernis).toBe('zu-wenige-gruppen'); |
| 294 | }); |
| 295 | }); |
| 296 | |
| 297 | /** Eine Signalgruppe ohne Bezug zum Lageplan - der unbelegte Fall. */ |
| 298 | function leereGruppe(id: string, name: string): Project['signalGroups'][number] { |
| 299 | return { |
| 300 | ...createSignalGroup({ mode: 'kfz', name }), |
| 301 | id, |
| 302 | }; |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Vier Stroeme, deren Vertraeglichkeit einen Viererkreis bildet: A vertraegt |
| 307 | * sich mit B und C, D mit B und C - aber A nicht mit D und B nicht mit C. |
| 308 | * |
| 309 | * Erst diese Form ergibt ZWEI verschiedene Zweiphaseneinteilungen ({A,B} + |
| 310 | * {C,D} und {A,C} + {B,D}) und dazu dreiphasige. Ein Pruefstueck mit nur einer |
| 311 | * moeglichen Einteilung koennte eine Reihung nicht pruefen: Eine Liste mit |
| 312 | * einem Eintrag ist in jeder Reihenfolge sortiert. |
| 313 | */ |
| 314 | function vierStroeme(): Project { |
| 315 | const erweitert: Lageplan = { |
| 316 | ...lageplan(), |
| 317 | linien: [ |
| 318 | linie('a', 'A', [ |
| 319 | { x: 0, y: 100 }, |
| 320 | { x: 200, y: 100 }, |
| 321 | ]), |
| 322 | linie('b', 'B', [ |
| 323 | { x: 0, y: 160 }, |
| 324 | { x: 200, y: 160 }, |
| 325 | ]), |
| 326 | // Kreuzt B, nicht A. |
| 327 | linie('c', 'C', [ |
| 328 | { x: 40, y: 140 }, |
| 329 | { x: 40, y: 260 }, |
| 330 | ]), |
| 331 | // Kreuzt A, nicht B. |
| 332 | linie('d', 'D', [ |
| 333 | { x: 100, y: 0 }, |
| 334 | { x: 100, y: 130 }, |
| 335 | ]), |
| 336 | ], |
| 337 | }; |
| 338 | const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: erweitert }; |
| 339 | const gruppen = uebernimmSignalgruppen( |
| 340 | leer, |
| 341 | leer.lageplan, |
| 342 | leer.lageplan.linien.map((l) => l.id), |
| 343 | ); |
| 344 | const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan }; |
| 345 | const ableitung = leiteAb(mitPlan, mitPlan.lageplan); |
| 346 | return uebernimmWege( |
| 347 | mitPlan, |
| 348 | mitPlan.lageplan, |
| 349 | ableitung, |
| 350 | ableitung.wege.map((w) => wegSchluessel(w)), |
| 351 | STICHTAG, |
| 352 | ).project; |
| 353 | } |
| 354 | |
| 355 | describe('Phaseneinteilung - gezaehlt wird, bevor aufgezaehlt wird', () => { |
| 356 | it('bricht ab, wo zu viele Phasenmengen möglich sind', () => { |
| 357 | /* |
| 358 | * DAS ZWEITE RISIKO DES BEFUNDS: Der Suchraum haengt an der Zahl der |
| 359 | * ERFASSTEN Konflikte, nicht an der Zahl der Gruppen - ausgerechnet das |
| 360 | * schlecht gepflegte Projekt haette den groessten Suchraum und bekaeme die |
| 361 | * meisten Vorschlaege. |
| 362 | * |
| 363 | * Gebaut ist hier der schlimmste Fall in klein: sieben Dreiergruppen, die |
| 364 | * untereinander alle vertraeglich sind, innerhalb der Gruppe aber nicht. |
| 365 | * Das ergibt 3^7 = 2187 maximale Mengen - weit ueber der Schranke. |
| 366 | */ |
| 367 | const ids = Array.from({ length: 21 }, (_, i) => `g${String(i)}`); |
| 368 | const drittel = (id: string): number => Math.floor(Number(id.slice(1)) / 3); |
| 369 | const ergebnis = maximaleMengen(ids, (a, b) => drittel(a) !== drittel(b)); |
| 370 | expect(ergebnis.abgebrochen).toBe(true); |
| 371 | expect(ergebnis.mengen.length).toBeLessThanOrEqual(MAX_PHASENMENGEN); |
| 372 | }); |
| 373 | |
| 374 | it('zählt bei überschaubaren Verhältnissen zu Ende', () => { |
| 375 | const ergebnis = maximaleMengen( |
| 376 | ['a', 'b', 'c'], |
| 377 | (x, y) => !(x === 'a' && y === 'b') && !(x === 'b' && y === 'a'), |
| 378 | ); |
| 379 | expect(ergebnis.abgebrochen).toBe(false); |
| 380 | expect(ergebnis.mengen.map((m) => [...m].sort().join('+')).sort()).toEqual(['a+c', 'b+c']); |
| 381 | }); |
| 382 | }); |
| 383 | |
| 384 | describe('Phaseneinteilung - der Belegstand selbst', () => { |
| 385 | it('unterscheidet drei Zustände und wirft keinen in den anderen', () => { |
| 386 | const projekt = belegtesProjekt(); |
| 387 | const arten = new Set(vertraeglichkeitsbelege(projekt).paare.map((p) => p.art)); |
| 388 | expect(arten.has('feindlich')).toBe(true); |
| 389 | expect(arten.has('belegt')).toBe(true); |
| 390 | expect(arten.has('ohne-beleg')).toBe(false); |
| 391 | }); |
| 392 | |
| 393 | it('lässt ohne auswertbaren Lageplan nichts als belegt gelten', () => { |
| 394 | const projekt = belegtesProjekt(); |
| 395 | const ohneMassstab: Project = { |
| 396 | ...projekt, |
| 397 | lageplan: { ...projekt.lageplan, kalibrierung: null }, |
| 398 | }; |
| 399 | const stand = vertraeglichkeitsbelege(ohneMassstab); |
| 400 | expect(stand.paare.some((p) => p.art === 'belegt')).toBe(false); |
| 401 | expect(stand.vollstaendig).toBe(false); |
| 402 | }); |
| 403 | }); |