lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain signalPlan.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { buildSignalPlan, cyclicRuns, intergreenBetween } from '@/domain/plan/signalPlan'; |
| 3 | import { aspectSegments, aspectTotals, isGreenAt } from '@/domain/plan/aspects'; |
| 4 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 5 | import type { Project } from '@/domain/model/project'; |
| 6 | |
| 7 | const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); |
| 8 | |
| 9 | function standard(): Project { |
| 10 | return createStandardIntersectionProject('Prueffall', FIXED_DATE); |
| 11 | } |
| 12 | |
| 13 | describe('Aufbau des Signalzeitenplans', () => { |
| 14 | it('erzeugt einen in sich schluessigen Umlauf', () => { |
| 15 | const plan = buildSignalPlan(standard()); |
| 16 | |
| 17 | expect(plan.feasible).toBe(true); |
| 18 | expect(plan.cycleTime).toBeGreaterThan(0); |
| 19 | |
| 20 | // Die Summe aus Freigabe- und Uebergangszeiten muss exakt die Umlaufzeit ergeben. |
| 21 | const phaseSum = plan.phases.reduce((a, p) => a + p.duration, 0); |
| 22 | expect(phaseSum + plan.transitionTime).toBeCloseTo(plan.cycleTime, 6); |
| 23 | }); |
| 24 | |
| 25 | it('leitet die Uebergangszeit aus der groessten Zwischenzeit ab', () => { |
| 26 | const plan = buildSignalPlan(standard()); |
| 27 | |
| 28 | for (const transition of plan.transitions) { |
| 29 | if (transition.critical === null) continue; |
| 30 | expect(transition.duration).toBe(transition.critical.intergreen); |
| 31 | |
| 32 | // Kein anderes Paar darf eine groessere Zwischenzeit verlangen. |
| 33 | const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!; |
| 34 | const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!; |
| 35 | for (const endingId of fromPhase.signalGroupIds) { |
| 36 | if (toPhase.signalGroupIds.includes(endingId)) continue; |
| 37 | for (const startingId of toPhase.signalGroupIds) { |
| 38 | if (fromPhase.signalGroupIds.includes(startingId)) continue; |
| 39 | expect(intergreenBetween(plan.intergreens, endingId, startingId)).toBeLessThanOrEqual( |
| 40 | transition.duration, |
| 41 | ); |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | }); |
| 46 | |
| 47 | it('haelt fuer jede Signalgruppe die Mindestfreigabezeit ein', () => { |
| 48 | const plan = buildSignalPlan(standard()); |
| 49 | for (const group of plan.groups) { |
| 50 | if (group.greens.length === 0) continue; |
| 51 | expect(group.totalGreen).toBeGreaterThanOrEqual(group.times.minGreen); |
| 52 | } |
| 53 | }); |
| 54 | |
| 55 | it('gibt feindliche Signalgruppen nie gleichzeitig frei', () => { |
| 56 | const project = standard(); |
| 57 | const plan = buildSignalPlan(project); |
| 58 | |
| 59 | // Jede erfasste Konfliktbeziehung wird ueber den gesamten Umlauf in |
| 60 | // Schritten von 0,5 s geprueft. |
| 61 | for (const conflict of project.conflicts) { |
| 62 | const from = plan.groups.find((g) => g.groupId === conflict.fromId)!; |
| 63 | const to = plan.groups.find((g) => g.groupId === conflict.toId)!; |
| 64 | for (let t = 0; t < plan.cycleTime; t += 0.5) { |
| 65 | const bothGreen = isGreenAt(from, t, plan.cycleTime) && isGreenAt(to, t, plan.cycleTime); |
| 66 | expect(bothGreen, `${from.name} und ${to.name} bei t=${t}s`).toBe(false); |
| 67 | } |
| 68 | } |
| 69 | }); |
| 70 | |
| 71 | it('haelt die Zwischenzeit zwischen Freigabeende und Freigabebeginn ein', () => { |
| 72 | const project = standard(); |
| 73 | const plan = buildSignalPlan(project); |
| 74 | |
| 75 | for (const transition of plan.transitions) { |
| 76 | const fromPhase = plan.phases.find((p) => p.id === transition.fromPhaseId)!; |
| 77 | const toPhase = plan.phases.find((p) => p.id === transition.toPhaseId)!; |
| 78 | const endingIds = fromPhase.signalGroupIds.filter( |
| 79 | (id) => !toPhase.signalGroupIds.includes(id), |
| 80 | ); |
| 81 | const startingIds = toPhase.signalGroupIds.filter( |
| 82 | (id) => !fromPhase.signalGroupIds.includes(id), |
| 83 | ); |
| 84 | |
| 85 | for (const startingId of startingIds) { |
| 86 | const starting = plan.groups.find((g) => g.groupId === startingId)!; |
| 87 | const greenStart = starting.greens |
| 88 | .map((g) => g.start) |
| 89 | .find((s) => Math.abs(s - transition.start) <= transition.duration + 0.001); |
| 90 | if (greenStart === undefined) continue; |
| 91 | |
| 92 | for (const endingId of endingIds) { |
| 93 | const required = intergreenBetween(plan.intergreens, endingId, startingId); |
| 94 | const actual = greenStart - transition.start; |
| 95 | expect(actual + 1e-6).toBeGreaterThanOrEqual(required); |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | }); |
| 100 | |
| 101 | it('meldet eine Signalgruppe ohne Phasenzuordnung', () => { |
| 102 | const base = standard(); |
| 103 | const project: Project = { |
| 104 | ...base, |
| 105 | phases: base.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.slice(0, 1) })), |
| 106 | }; |
| 107 | const plan = buildSignalPlan(project); |
| 108 | expect(plan.notes.some((n) => n.code === 'signalgruppe-ohne-phase')).toBe(true); |
| 109 | }); |
| 110 | |
| 111 | it('meldet fehlende Phasen statt einen leeren Plan zu zeichnen', () => { |
| 112 | const base = standard(); |
| 113 | const plan = buildSignalPlan({ |
| 114 | ...base, |
| 115 | phases: [], |
| 116 | program: { ...base.program, phaseOrder: [] }, |
| 117 | }); |
| 118 | expect(plan.feasible).toBe(false); |
| 119 | expect(plan.notes.some((n) => n.code === 'keine-phasen')).toBe(true); |
| 120 | }); |
| 121 | |
| 122 | it('weist eine zu kurz vorgegebene feste Umlaufzeit zurueck', () => { |
| 123 | const base = standard(); |
| 124 | const plan = buildSignalPlan({ |
| 125 | ...base, |
| 126 | program: { ...base.program, method: 'manuell', manualCycleTime: 20 }, |
| 127 | }); |
| 128 | expect(plan.notes.some((n) => n.code === 'umlaufzeit-zu-kurz')).toBe(true); |
| 129 | expect(plan.cycleTime).toBeGreaterThan(20); |
| 130 | }); |
| 131 | |
| 132 | it('uebernimmt eine von Hand gesetzte Zwischenzeit', () => { |
| 133 | const base = standard(); |
| 134 | const first = base.conflicts[0]!; |
| 135 | const plan = buildSignalPlan({ |
| 136 | ...base, |
| 137 | conflicts: base.conflicts.map((c) => (c.id === first.id ? { ...c, manualIntergreen: 9 } : c)), |
| 138 | }); |
| 139 | const resolved = plan.intergreens.get(`${first.fromId}|${first.toId}`)!; |
| 140 | expect(resolved.value).toBe(9); |
| 141 | expect(resolved.source).toBe('vorgegeben'); |
| 142 | }); |
| 143 | }); |
| 144 | |
| 145 | describe('Zyklische Phasenlaeufe', () => { |
| 146 | it('erkennt einen zusammenhaengenden Lauf', () => { |
| 147 | expect(cyclicRuns([false, true, true, false])).toEqual([[1, 2]]); |
| 148 | }); |
| 149 | |
| 150 | it('erkennt einen Lauf ueber die Umlaufgrenze hinweg', () => { |
| 151 | expect(cyclicRuns([true, false, false, true])).toEqual([[3, 0]]); |
| 152 | }); |
| 153 | |
| 154 | it('erkennt zwei getrennte Laeufe', () => { |
| 155 | expect(cyclicRuns([true, false, true, false])).toEqual([[0], [2]]); |
| 156 | }); |
| 157 | |
| 158 | it('behandelt Dauerfreigabe und Dauerrot', () => { |
| 159 | expect(cyclicRuns([true, true, true])).toEqual([[0, 1, 2]]); |
| 160 | expect(cyclicRuns([false, false])).toEqual([]); |
| 161 | }); |
| 162 | }); |
| 163 | |
| 164 | describe('Signalbilder', () => { |
| 165 | it('zerlegt den Umlauf lueckenlos und ueberschneidungsfrei', () => { |
| 166 | const plan = buildSignalPlan(standard()); |
| 167 | for (const group of plan.groups) { |
| 168 | const segments = aspectSegments(group, plan.cycleTime); |
| 169 | const total = segments.reduce((a, s) => a + s.duration, 0); |
| 170 | expect(total).toBeCloseTo(plan.cycleTime, 3); |
| 171 | |
| 172 | let cursor = 0; |
| 173 | for (const segment of segments) { |
| 174 | expect(segment.start).toBeCloseTo(cursor, 3); |
| 175 | cursor += segment.duration; |
| 176 | } |
| 177 | } |
| 178 | }); |
| 179 | |
| 180 | it('ordnet Rot-Gelb vor und Gelb nach der Freigabezeit an', () => { |
| 181 | const plan = buildSignalPlan(standard()); |
| 182 | const group = plan.groups.find((g) => g.mode === 'kfz' && g.greens.length === 1)!; |
| 183 | const totals = aspectTotals(group, plan.cycleTime); |
| 184 | |
| 185 | expect(totals.rotgelb).toBeCloseTo(group.times.redYellow, 3); |
| 186 | expect(totals.gelb).toBeCloseTo(group.times.yellow, 3); |
| 187 | expect(totals.gruen).toBeCloseTo(group.totalGreen, 3); |
| 188 | expect(totals.rot).toBeGreaterThan(0); |
| 189 | }); |
| 190 | |
| 191 | it('gibt Fussgaengern weder Gelb noch Rot-Gelb', () => { |
| 192 | const plan = buildSignalPlan(standard()); |
| 193 | const pedestrian = plan.groups.find((g) => g.mode === 'fuss')!; |
| 194 | const totals = aspectTotals(pedestrian, plan.cycleTime); |
| 195 | expect(totals.gelb).toBe(0); |
| 196 | expect(totals.rotgelb).toBe(0); |
| 197 | }); |
| 198 | }); |