lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests domain freigabezeitrestVerteilung.test.ts
| 1 | import { describe, expect, it } from 'vitest'; |
| 2 | import { distributeGreenTimes } from '@/domain/rilsa/greenSplit'; |
| 3 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 4 | import { |
| 5 | createConflict, |
| 6 | createEmptyProject, |
| 7 | createPhase, |
| 8 | createSignalGroup, |
| 9 | } from '@/domain/model/factory'; |
| 10 | import type { Project } from '@/domain/model/project'; |
| 11 | |
| 12 | /** |
| 13 | * Der Rest der Freigabezeitverteilung geht an alle Phasen mit Spielraum, nicht |
| 14 | * an eine. |
| 15 | * |
| 16 | * DER ANLASS IST EIN NACHGERECHNETER PLAN, keine Ueberlegung am Quelltext: ein |
| 17 | * angeordneter Plan, einstreifige Verkehrsfuehrung, zwei Richtungen, |
| 18 | * derselbe Raeumweg, dieselbe Zwischenzeit, dieselbe Hoechstfreigabezeit - und |
| 19 | * das Programm gab der einen Richtung 44 s Gruen und der anderen 40 s. An einer |
| 20 | * Baustellenampel ist das nicht erklaerbar: Es gibt nichts, was die beiden |
| 21 | * Richtungen unterscheidet. Die Frage "wie kann es hier ueberhaupt einen |
| 22 | * Unterschied geben" hat die Stelle gefunden. |
| 23 | * |
| 24 | * WAS DAHINTER STAND. Liegt jede Phase an ihrer Hoechstfreigabezeit und bleibt |
| 25 | * von der festen Umlaufzeit trotzdem etwas uebrig, muss dieser Rest vergeben |
| 26 | * werden - die Summe der Phasendauern hat die Umlaufzeit zu treffen. Bis 5.22.0 |
| 27 | * ging er ganz an die Phase mit dem groessten Gewicht. Ohne Verkehrsstaerken |
| 28 | * sind alle Gewichte gleich; dann entschied der Index, und das war die erste |
| 29 | * Phase. Ein Gleichstand ist keine Auskunft darueber, wer die Zeit bekommen |
| 30 | * soll. |
| 31 | * |
| 32 | * WAS JETZT GILT: Der Rest geht nach Gewichten an die Phasen, die einen Bereich |
| 33 | * haben - und wo keine Verkehrsstaerken vorliegen, zu gleichen Teilen. Phasen, |
| 34 | * deren Dauer festliegt (`minGreen === maxGreen`), bleiben aussen vor, solange |
| 35 | * es andere gibt. Erst wenn jede Phase festliegt, werden alle ueberschritten. |
| 36 | * |
| 37 | * MUTATIONSNACHWEIS (10.09.2026, sechs Mutationen an |
| 38 | * `src/domain/rilsa/greenSplit.ts`): Jede wird gefunden. Der Rueckfall auf die |
| 39 | * erste Phase reisst sechs der neun Faelle nieder, ein unterbliebenes Vergeben |
| 40 | * des Rests ebenfalls sechs; die Verteilung ueber wirklich alle Phasen, das |
| 41 | * Uebergehen der Verkehrsstaerken und die beiden verstuemmelten Meldungen je |
| 42 | * einen bis zwei. Zwei Faelle bleiben bei jeder Mutation gruen, und das ist |
| 43 | * richtig so: Sie sind die Gegenproben mit einer Umlaufzeit von 96 s, bei der |
| 44 | * gar kein Rest entsteht. |
| 45 | */ |
| 46 | |
| 47 | const T = new Date('2026-01-22T00:00:00Z'); |
| 48 | |
| 49 | /** Zwei gleichgestellte Richtungen einer einstreifigen Fuehrung. */ |
| 50 | function zweiRichtungen(umlauf: number, gewicht = 0) { |
| 51 | return distributeGreenTimes( |
| 52 | umlauf, |
| 53 | [ |
| 54 | { id: 'a', weight: gewicht, minGreen: 10, maxGreen: 40 }, |
| 55 | { id: 'b', weight: gewicht, minGreen: 10, maxGreen: 40 }, |
| 56 | ], |
| 57 | [8, 8], |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | const gruen = (ergebnis: ReturnType<typeof distributeGreenTimes>): number[] => |
| 62 | ergebnis.phases.map((p) => p.green); |
| 63 | |
| 64 | const restmeldung = (ergebnis: ReturnType<typeof distributeGreenTimes>) => |
| 65 | ergebnis.notes.find((n) => n.code === 'freigabezeit-rest'); |
| 66 | |
| 67 | describe('Der Rest der Freigabezeitverteilung', () => { |
| 68 | it('teilt sich gleichmaessig, wo nichts die Phasen unterscheidet', () => { |
| 69 | // 100 s minus 2 x 8 s Zwischenzeit sind 84 s; zwei Hoechstfreigabezeiten |
| 70 | // von 40 s nehmen davon 80 s auf. Die uebrigen 4 s gehoeren beiden. |
| 71 | expect(gruen(zweiRichtungen(100))).toEqual([42, 42]); |
| 72 | }); |
| 73 | |
| 74 | it('nennt die Meldung beide Phasen und die Kuerzung, die den Rest beseitigt', () => { |
| 75 | const meldung = restmeldung(zweiRichtungen(100)); |
| 76 | expect(meldung, 'Restmeldung').toBeDefined(); |
| 77 | expect(meldung!.severity).toBe('warnung'); |
| 78 | expect(meldung!.message, 'die Zahl des Rests').toContain('4 s der Umlaufzeit bleiben übrig'); |
| 79 | expect(meldung!.message, 'beide Empfaenger').toContain('Sie gehen an Phase 1, Phase 2'); |
| 80 | /* |
| 81 | * Die Zahl, die weiterhilft: 100 - 4 = 96 s ist die groesste Umlaufzeit, |
| 82 | * die sich mit diesen Hoechstfreigabezeiten einhalten laesst. Bis 5.22.0 |
| 83 | * stand dort nur "Verkürzen Sie die Umlaufzeit", ohne Betrag - der Planer |
| 84 | * musste ihn selbst ausrechnen. |
| 85 | */ |
| 86 | expect(meldung!.message, 'der Weg heraus').toContain('Verkürzen Sie die Umlaufzeit um 4 s'); |
| 87 | }); |
| 88 | |
| 89 | it('schweigt bei der Umlaufzeit, die aufgeht', () => { |
| 90 | const ergebnis = zweiRichtungen(96); |
| 91 | expect(gruen(ergebnis)).toEqual([40, 40]); |
| 92 | expect(restmeldung(ergebnis), 'ohne Rest keine Meldung').toBeUndefined(); |
| 93 | }); |
| 94 | |
| 95 | it('laesst eine von Hand vorgegebene Freigabezeit unangetastet', () => { |
| 96 | /* |
| 97 | * GEGENPROBE ZUR GLEICHVERTEILUNG, und sie hat den ersten Anlauf dieser |
| 98 | * Aenderung umgeworfen: Wer eine feste Freigabezeit eintraegt, hat |
| 99 | * entschieden. Der Rest gehoert dann ganz an die Phase mit Bereich, auch |
| 100 | * wenn das unsymmetrisch aussieht - hier unterscheidet die Vorgabe die |
| 101 | * beiden Phasen sehr wohl. |
| 102 | */ |
| 103 | const ergebnis = distributeGreenTimes( |
| 104 | 80, |
| 105 | [ |
| 106 | { id: 'fest', weight: 0.4, minGreen: 6, maxGreen: 6 }, |
| 107 | { id: 'frei', weight: 0.6, minGreen: 10, maxGreen: 60 }, |
| 108 | ], |
| 109 | [5, 5], |
| 110 | ); |
| 111 | expect(gruen(ergebnis)).toEqual([6, 64]); |
| 112 | expect(restmeldung(ergebnis)!.message, 'nur die Phase mit Bereich').toContain( |
| 113 | 'Sie gehen an Phase 2 –', |
| 114 | ); |
| 115 | expect(restmeldung(ergebnis)!.message, 'die Zusage an die Vorgabe').toContain( |
| 116 | 'Phasen, deren Freigabezeit festliegt, bleiben unangetastet.', |
| 117 | ); |
| 118 | }); |
| 119 | |
| 120 | it('ueberschreitet alle Vorgaben, wo keine Phase mehr Spielraum hat - und sagt es', () => { |
| 121 | const ergebnis = distributeGreenTimes( |
| 122 | 80, |
| 123 | [ |
| 124 | { id: 'a', weight: 0, minGreen: 20, maxGreen: 20 }, |
| 125 | { id: 'b', weight: 0, minGreen: 20, maxGreen: 20 }, |
| 126 | ], |
| 127 | [5, 5], |
| 128 | ); |
| 129 | // 70 s verfuegbar, 40 s vorgegeben: Die 30 s Rest lassen sich nirgends |
| 130 | // sonst unterbringen. |
| 131 | expect(gruen(ergebnis)).toEqual([35, 35]); |
| 132 | const meldung = restmeldung(ergebnis); |
| 133 | expect(meldung!.message, 'der Vorbehalt faellt fort').not.toContain('bleiben unangetastet'); |
| 134 | expect(meldung!.message, 'und wird durch das Gegenteil ersetzt').toContain( |
| 135 | 'Keine Phase hat dabei Spielraum; auch eine fest vorgegebene Freigabezeit wird überschritten.', |
| 136 | ); |
| 137 | }); |
| 138 | |
| 139 | it('folgt der Verkehrsstaerke, wo eine vorliegt', () => { |
| 140 | /* |
| 141 | * Gleichverteilung ist nicht das Ziel, sondern die Folge fehlender |
| 142 | * Angaben. Wo Verkehrsstaerken erfasst sind, ist eine unterschiedliche |
| 143 | * Freigabezeit begruendet - und dann soll sie auch unterschiedlich sein. |
| 144 | */ |
| 145 | const ergebnis = zweiRichtungen(100, 0); |
| 146 | const gewichtet = distributeGreenTimes( |
| 147 | 100, |
| 148 | [ |
| 149 | { id: 'a', weight: 0.6, minGreen: 10, maxGreen: 40 }, |
| 150 | { id: 'b', weight: 0.2, minGreen: 10, maxGreen: 40 }, |
| 151 | ], |
| 152 | [8, 8], |
| 153 | ); |
| 154 | expect(gruen(ergebnis), 'ohne Verkehrsstaerken gleich').toEqual([42, 42]); |
| 155 | expect(gruen(gewichtet), 'mit Verkehrsstaerken nach Bedarf').toEqual([43, 41]); |
| 156 | }); |
| 157 | |
| 158 | it('trifft die Summe in jedem dieser Faelle die verfuegbare Zeit', () => { |
| 159 | // Der Grund, aus dem der Rest ueberhaupt vergeben werden muss. Ein |
| 160 | // Signalzeitenplan, dessen Phasendauern die Umlaufzeit verfehlen, ist |
| 161 | // nicht schaltbar. |
| 162 | for (const umlauf of [96, 97, 98, 99, 100, 101, 110]) { |
| 163 | const ergebnis = zweiRichtungen(umlauf); |
| 164 | const summe = gruen(ergebnis).reduce((a, b) => a + b, 0); |
| 165 | expect(summe, `Umlaufzeit ${umlauf} s`).toBe(ergebnis.availableGreen); |
| 166 | } |
| 167 | }); |
| 168 | }); |
| 169 | |
| 170 | /** |
| 171 | * Und derselbe Sachverhalt am ganzen Plan statt an der Verteilung allein: Der |
| 172 | * nachgerechnete Plan B, aufgebaut aus den Angaben der Anordnung. |
| 173 | */ |
| 174 | describe('Die einstreifige Fuehrung von Plan B, nachgebaut', () => { |
| 175 | function planB(umlauf: number): Project { |
| 176 | const basis = createEmptyProject('Plan B', T, 'einstreifig'); |
| 177 | const richtung = (name: string, index: number) => ({ |
| 178 | ...createSignalGroup({ name, mode: 'kfz' as const, vZul: 50, index }), |
| 179 | minGreenOverride: 10, |
| 180 | maxGreenOverride: 40, |
| 181 | }); |
| 182 | const k1 = richtung('K1', 0); |
| 183 | const k2 = richtung('K2', 1); |
| 184 | const p1 = createPhase('Seite A', [k1.id]); |
| 185 | const p2 = createPhase('Seite B', [k2.id]); |
| 186 | const beziehung = (von: string, nach: string) => ({ |
| 187 | ...createConflict(von, nach), |
| 188 | clearingDistance: 40, |
| 189 | enteringDistance: 0, |
| 190 | }); |
| 191 | return { |
| 192 | ...basis, |
| 193 | signalGroups: [k1, k2], |
| 194 | phases: [p1, p2], |
| 195 | conflicts: [beziehung(k1.id, k2.id), beziehung(k2.id, k1.id)], |
| 196 | program: { |
| 197 | ...basis.program, |
| 198 | phaseOrder: [p1.id, p2.id], |
| 199 | manualCycleTime: umlauf, |
| 200 | method: 'manuell' as const, |
| 201 | }, |
| 202 | }; |
| 203 | } |
| 204 | |
| 205 | it('gibt beiden Richtungen dieselbe Freigabezeit', () => { |
| 206 | const plan = buildSignalPlan(planB(100)); |
| 207 | expect(plan.cycleTime).toBe(100); |
| 208 | expect( |
| 209 | plan.transitions.map((t) => t.duration), |
| 210 | 'Zwischenzeit je Richtung', |
| 211 | ).toEqual([8, 8]); |
| 212 | // Bis 5.22.0 standen hier 44 und 40 s. |
| 213 | expect(plan.phases.map((p) => p.duration)).toEqual([42, 42]); |
| 214 | }); |
| 215 | |
| 216 | it('braucht 96 s, damit die Hoechstfreigabezeit von 40 s eingehalten ist', () => { |
| 217 | const plan = buildSignalPlan(planB(96)); |
| 218 | expect(plan.phases.map((p) => p.duration)).toEqual([40, 40]); |
| 219 | expect( |
| 220 | plan.notes.map((n) => n.code), |
| 221 | 'bei 96 s bleibt nichts uebrig', |
| 222 | ).not.toContain('freigabezeit-rest'); |
| 223 | }); |
| 224 | }); |