import { aspectTotals } from '@/domain/plan/aspects'; import { UMLAUFZEIT_ERSATZWERT_VERMERK, hatSaettigungsverkehrsstaerke, intergreenKey, umlaufzeitIstErsatzwert, type GreenInterval, type SignalPlan, } from '@/domain/plan/signalPlan'; import type { Seconds } from '@/domain/units'; import { MOVEMENT_LABELS, TRAFFIC_MODE_LABELS, fahrzeugartBeschriftung, } from '@/domain/rilsa/constants'; import { ueberfahrzeitHerkunft } from '@/domain/rilsa/ansaetze'; import type { Project, SignalGroup } from '@/domain/model/project'; import { KRITERIUM_LABELS, engerRadiusZelle, // Anteil, Aufteilung, Faktor und Gleichung eines Stroms - aus derselben // Funktion des Fachkerns, mit der der Plan die Saettigungsverkehrsstaerke // bildet (plan/signalPlan.ts, groupSaturationFlow), und mit denselben Worten // wie im Ausdruck (Fassung 5.6.0, Frage E6). schwerverkehrsangabe, verfahrenLabel, } from './bewertung'; import { wegherkunftZelle } from './wegherkunft'; import { CATEGORY_LABELS, SEVERITY_LABELS, type ValidationReport } from '@/domain/validation'; import * as fmt from '@/ui/format'; /** * Tabellenausgabe fuer die Weiterverarbeitung. * * Trennzeichen ist das Semikolon und Dezimaltrennzeichen das Komma - so oeffnet * Excel in deutscher Einstellung die Datei ohne Importdialog. Zusaetzlich wird * eine Byte-Reihenfolge-Markierung vorangestellt, damit Umlaute richtig * erkannt werden; ohne sie zeigte der Altbestand in Excel "ü" statt "ü". * * Die vier PLAN-Tabellen (Signalgruppen, beide Zwischenzeiten, Phasen) nehmen * seit Fassung 5.4.0 (Befund C7) einen Pruefkontext entgegen und * schreiben bei Warnungen einen Vermerk als letzte Zeile (csvVermerke, dort * die Begruendung fuer "letzte" statt "erste"). Der Pruefbericht selbst bleibt * ohne - er IST der Kontext. */ const SEPARATOR = ';'; const BOM = ''; /* * Zeichen, mit denen eine Tabellenkalkulation eine Formel beginnen laesst. * * Bemerkungen und Beanstandungen sind freier Text. Beginnt eine Zelle mit * einem dieser Zeichen, wertet Excel sie beim Oeffnen als Formel aus - eine * Bemerkung "=Bordkante" wird zum Bezugsfehler, und boesartig gebaute Inhalte * koennen weiter gehen. Ein vorangestelltes Hochkomma macht daraus wieder * Text; die Tabellenkalkulation zeigt es nicht an. */ const FORMELSTART = /^[=+\-@\t\r]/; /** * Eine Zahl in deutscher Schreibweise - die darf NICHT entwertet werden. * * Der Rechenwert der Zwischenzeit kann negativ sein (tue + tr - te vor dem * Abschneiden auf 0), und ein vorangestelltes Hochkomma machte aus dieser Zahl * Text. Die Spalte waere in jeder Auswertung unbrauchbar. */ const ZAHL = /^-?\d+(?:,\d+)?$/; function escape(value: string): string { const sicher = FORMELSTART.test(value) && !ZAHL.test(value) ? `'${value}` : value; // Auch das einzeln stehende CR. Der Datensatztrenner ist \r\n; ein CR ohne // folgendes LF fiel durch die Pruefung auf \n und zerriss die Zeile. Zu // erreichen ueber ein Bemerkungsfeld, in dem ein CR steht - etwa aus einer // eingelesenen Projektdatei. if (sicher.includes(SEPARATOR) || /["\r\n]/.test(sicher)) { return `"${sicher.replace(/"/g, '""')}"`; } return sicher; } function toCsv(rows: readonly (readonly string[])[]): string { return BOM + rows.map((row) => row.map(escape).join(SEPARATOR)).join('\r\n'); } /** Wahrheitswert als Anwendertext - "ja"/"nein" statt "true"/"false". */ function jaNein(wert: boolean): string { return wert ? 'ja' : 'nein'; } /** * Was eine PLAN-Tabelle ueber das Pruefergebnis wissen muss. * * Ein ganzer ValidationReport erfuellt das ebenso wie `{ warningCount: n }`; * mehr braucht die Tabelle nicht, und mehr soll sie nicht sehen. Die * Fehlerzahl ist freiwillig, weil die Oberflaeche eine Tabelle mit Fehlern * gar nicht erst ausgibt (Sperre); wer die Funktionen unmittelbar aufruft, * bekommt mit ihr den Fehler-Vermerk. */ export type CsvPruefkontext = Pick & Partial>; /** * Vermerkzeilen einer Plan-Tabelle - als LETZTE Zeilen, nach den Daten. * * KORREKTUR (Befund C7): Die Plan-Tabellen (Signalgruppen, Zwischenzeiten, * Phasen) verliessen das Programm ohne jeden Bezug zum Pruefbericht - wer die * Datei weiterreichte, reichte die Zahlen ohne die Warnungen weiter, die * dazugehoeren. Jetzt steht in der Datei, dass der Pruefbericht Warnungen * meldet. Die Zeile ist eine einzelne Zelle und keine Datenzeile. * * Zugleich der Ort fuer den Ersatzwert-Vermerk (Befund C11): Ist die * Umlaufzeit nur ein Ersatzwert (uebersaettigt), stehen Rotzeiten, * Kapazitaeten und Phasenbeginne auf einer Zahl, die kein Bemessungsergebnis * ist. Das muss in der Datei stehen, nicht nur am Bildschirm. * * WARUM AM ENDE UND NICHT IN ZEILE 1: Die Kopfzeile bleibt Zeile 1. Erstens * erwarten die bestehenden Tabellen-Tests sie dort - auch fuer einen * uebersaettigten Plan (csv.test.ts, "nennt bei Ueberlastung das Kriterium"), * der den Ersatzwert-Vermerk auch OHNE Pruefkontext bekommt; ein Vermerk in * Zeile 1 schoebe die Kopfzeile dort weg. Zweitens sind diese Tabellen zum * Auswerten gedacht ("die Stufe soll filterbar bleiben"): Excel-Autofilter, * PowerQuery und jeder CSV-Leser mit Kopfzeile nehmen Zeile 1 als Kopf; eine * Hinweiszeile davor macht aus dem Vermerk den Spaltennamen und aus jeder * Datenzeile eine mit zu vielen Spalten. Am Ende ist der Vermerk eine * einzelne Textzelle unter der letzten Datenzeile - sichtbar in jeder * Tabellenkalkulation, unschaedlich fuer jede Auswertung. */ export function csvVermerke( plan: Pick | null, kontext?: CsvPruefkontext, ): string[][] { const zeilen: string[][] = []; // Fehler zuerst: Die Sperre der Oberflaeche laesst eine solche Tabelle nicht // hinaus; erzeugt sie jemand an ihr vorbei, sagt die Datei selbst, dass sie // keine freigegebene Planunterlage ist. if (kontext?.errorCount !== undefined && kontext.errorCount > 0) { const anzahl = kontext.errorCount === 1 ? '1 Fehler' : `${kontext.errorCount} Fehler`; zeilen.push([ `Hinweis: Prüfbericht meldet ${anzahl} – die Tabelle ist keine freigegebene Planunterlage; ` + 'siehe Prüfbericht', ]); } if (kontext !== undefined && kontext.warningCount > 0) { const anzahl = kontext.warningCount === 1 ? '1 Warnung' : `${kontext.warningCount} Warnungen`; zeilen.push([`Hinweis: Prüfbericht meldet ${anzahl} – siehe Prüfbericht`]); } if (plan !== null && umlaufzeitIstErsatzwert(plan)) { zeilen.push([ `Hinweis: Umlaufzeit ${fmt.seconds(plan.cycleTime)} ${UMLAUFZEIT_ERSATZWERT_VERMERK}; ` + 'alle umlaufabhängigen Werte dieser Tabelle beruhen auf dem Ersatzwert', ]); } return zeilen; } /** * ALLE Freigabezeitfenster einer Signalgruppe als eine Zelle: "12–40; 60–72". * * KORREKTUR (Befund C22): Die Signalzeitentabelle des Ausdrucks fuehrte zwei * Spalten "Freigabe von" und "Freigabe bis" und fuellte sie aus `greens[0]` - * dem ERSTEN Fenster. Daneben stand in "Freigabezeit" die SUMME aller Fenster. * Bei einer Signalgruppe, die in zwei getrennten Phasen freigegeben ist, ergab * das eine Zeile, die sich nicht mehr aufloesen laesst: "von 12 bis 40" neben * "Freigabezeit 40 s" - 28 s Fenster, 40 s Summe, und das zweite Fenster fehlt * ganz. Wer danach schaltet oder danach prueft, hat eine falsche * Signalzeitentabelle. Jetzt nennt eine Spalte alle Fenster, und die * Summenspalte heisst Summe. * * Zahlen mit hoechstens einer Nachkommastelle: Die Fenstergrenzen entstehen aus * der Freigabezeitverteilung und sind nicht ganzzahlig; auf ganze Sekunden * gerundet ginge die Summe der gedruckten Fenster nicht mehr gegen die * gedruckte Freigabezeit auf. * * WARUM HIER UND NICHT IM PDF: Ausdruck und Tabellenausgabe muessen dieselbe * Zeichenkette zeigen - wer beide nebeneinanderlegt, vergleicht Zeile fuer * Zeile. Die Funktion steht in der Tabellenausgabe, weil diese die * abhaengigkeitsaermere der beiden ist: Das PDF darf die Tabellenausgabe * einbinden, umgekehrt zoege die Tabellenausgabe die PDF-Bibliothek nach sich. */ export function freigabefensterZelle(greens: readonly GreenInterval[], cycleTime: Seconds): string { if (greens.length === 0) return '–'; // Dauerfreigabe ueber den ganzen Umlauf: Der Rueckfall in den Umlauf ergaebe // Ende 0 und damit "0–0" - im Altbestand stand in dieser Zeile "0 s / 0 s". if (greens.length === 1 && greens[0]!.duration >= cycleTime) { return `0–${fmt.numShort(cycleTime, 1)} (ganzer Umlauf)`; } return ( [...greens] /* * Nach Umlaufposition, nicht in Speicherreihenfolge: `greens` entsteht * aus der Phasenfolge und ist nicht sortiert - eine Gruppe mit den * Fenstern [116,28] und [56,28] stand als "116–24; 56–84" in der Zeile. * Eine Signalzeitentabelle wird von oben nach unten gegen den Umlauf * gelesen; eine Zeile, die rueckwaerts springt, liest sich wie ein Fehler * in der Schaltung. */ .sort((a, b) => a.start - b.start) .map((g) => { const roh = g.start + g.duration; const rest = roh % cycleTime; /* * Ein Fenster kann ueber den Umlaufbeginn hinausreichen; dann liegt das * Ende VOR dem Beginn ("72–12"). Das ist keine Verwechslung, sondern der * Sachverhalt, und die Fussnote der Tabelle sagt es. * * Schliesst ein Fenster dagegen GENAU am Umlaufende, ist der Rest 0 - * und "60–0" behauptete ein Fenster, das rueckwaerts laeuft, statt * eines, das bis zum Umlaufende reicht. Die Dauerfreigabe oben war * wegen derselben Modulo-Null gesondert behandelt, dieser Fall nicht. */ const ende = rest === 0 && g.duration > 0 ? cycleTime : rest; return `${fmt.numShort(g.start, 1)}–${fmt.numShort(ende, 1)}`; }) .join('; ') ); } /** * Fussnote zur Freigabezeit - im Ausdruck unter der Signalzeitentabelle, in der * Tabellenausgabe als Spaltenname (Befund C22). Beide Ausgaben muessen dasselbe * sagen: Die Freigabezeit ist die Summe, nicht die Dauer des ersten Fensters. */ export const FREIGABEFENSTER_FUSSNOTE = 'Freigabefenster: alle Zeitfenster mit Freigabe im Umlauf, in Sekunden ab Umlaufbeginn; mehrere ' + 'Fenster entstehen, wenn eine Signalgruppe in getrennten Phasen freigegeben ist. Reicht ein ' + 'Fenster über den Umlaufbeginn hinaus, liegt sein Ende vor seinem Beginn. Die Freigabezeit ist ' + 'die Summe aller Fenster, nicht die Dauer des ersten.'; /** * Die an einer Signalgruppe EINGETRAGENE Hoechstfreigabezeit - oder null, wenn * keine brauchbare eingetragen ist. * * WARUM SIE IN DIE UNTERLAGE GEHOERT: Seit der Fassung 5.10.0 wirkt * `SignalGroup.maxGreenOverride` in der Freigabezeitverteilung - * `phaseMaxGreen` (plan/signalPlan.ts) setzt fuer eine Phase die KLEINSTE * Vorgabe ihrer Signalgruppen an statt des Regelwerts. In keiner Ausgabe stand * sie: Die gedruckte Phasendauer liess sich aus den gedruckten Groessen nicht * mehr herleiten, und das ist der Zweck der Unterlage. * * DIE ZELLE NENNT DEN EINTRAG UND NICHT DIE GELTENDE SCHRANKE: Die erste * Fassung dieser Funktion setzte ohne Eintrag den Regelwert ein und sagte zu, * die Spalte nenne die Schranke, die gilt. Das war an drei Lagen widerlegt: bei * einer festen Freigabezeit der Phase (sie geht der Vorgabe vor), bei einer * Vorgabe unter der Mindestfreigabezeit (dann gilt diese - und der Pruefbericht * derselben Unterlage sagte das Gegenteil der Tabelle) und bei einem negativen * Eintrag, den das Einlesen durchlaesst. Vor allem aber ist die geltende * Schranke keine Eigenschaft EINER Signalgruppe: Sie entsteht je PHASE aus der * kleinsten Vorgabe aller ihrer Gruppen. Eine Zelle je Gruppe kann sie nicht * nennen, ohne zu behaupten, was sie nicht weiss. * * Damit steht die Auswahlregel auch nur noch an einer Stelle: `phaseMaxGreen` * im Fachkern entscheidet, was gilt; diese Funktion liest ein Feld. Dass die * Zelle eine Vorgabe fuehrt, sagt in der Tabellenausgabe der Spaltenname; wer * ueber die wirksame Schranke entscheidet und was ohne jede Vorgabe in einer * Phase gilt, sagt im Ausdruck der Absatz unter der Signalgruppentabelle * (pdf.ts, drawSignalGroupTable). * * NULL BEI EINEM UNBRAUCHBAREN EINTRAG: Ein nicht endlicher Wert hat keine * lesbare Schreibweise - "NaN s" waere keine Angabe. Das Einlesen hat ihn * bereits gemeldet und als nicht gesetzt behandelt (schema.ts, * `optionalNumGemeldet`), und `phaseMaxGreen` uebergeht ihn ebenso. * * WARUM HIER UND NICHT IM PDF: derselbe Grund wie bei `freigabefensterZelle` * darueber - Ausdruck und Tabellenausgabe muessen dieselbe Angabe zeigen, und * die Tabellenausgabe ist die abhaengigkeitsaermere der beiden. Die * Schreibweise der leeren Zelle bleibt jeder Ausgabe selbst ueberlassen: hier * leer wie jede nicht gebildete Groesse, im Ausdruck "–". */ export function hoechstfreigabezeitVorgabe(group: SignalGroup): Seconds | null { const vorgabe = group.maxGreenOverride; return vorgabe === null || !Number.isFinite(vorgabe) ? null : vorgabe; } /** Signalgruppen mit Signalzeiten und Leistungsfaehigkeit. */ export function signalGroupsCsv( project: Project, plan: SignalPlan, kontext?: CsvPruefkontext, ): string { const rows: string[][] = [ [ 'Signalgruppe', 'Verkehrsart', 'Fahrbeziehung', 'V zul [km/h]', 'Fahrstreifen', 'Fahrzeugart', // Merkmale der Fussgaengerfurt, die in die Mindestfreigabezeit eingehen // (Befund C1): Zusatzeinrichtung fuer Blinde und Sehbehinderte (Freigabe // fuer die ganze statt die halbe Furt) und erhoehter Zeitbedarf (1,0 // statt 1,2 m/s). "ja"/"nein" nur fuer Fussgaengergruppen, sonst leer - // wie jede Groesse, die fuer die Zeile nicht gebildet wird. Ohne die // Spalten stuende eine Mindestfreigabezeit von 6 s neben einem Regelwert // von 5 s ohne erkennbaren Grund. 'Blindenzusatz', 'Erhöhter Zeitbedarf', 'Rot-Gelb [s]', 'Gelb [s]', 'Mindestfreigabezeit [s]', /* * Die Vorgabe zur oberen Schranke der Freigabezeitverteilung, neben der * unteren (Fassung 5.10.0): Ohne sie stand in der Unterlage eine * Phasendauer, die sich aus den gedruckten Groessen nicht herleiten * liess. * * "VORGABE" STEHT IM SPALTENNAMEN: Die Zelle nennt den Eintrag der * Signalgruppe, nicht die im Plan wirksame Schranke - die bildet * `phaseMaxGreen` je PHASE aus der kleinsten Vorgabe ihrer Gruppen, und * eine feste Freigabezeit der Phase wie auch die Mindestfreigabezeit * gehen ihr vor (siehe `hoechstfreigabezeitVorgabe`). Leer heisst deshalb * "keine Vorgabe" und nicht "keine Schranke" - dieselbe Form wie bei der * Spalte "Lastzuganteil ... (leer = nicht erfasst)". */ 'Höchstfreigabezeit: Vorgabe der Signalgruppe [s] (leer = keine)', // Befund C22: Ohne diese Spalte stand die Summe der Freigabezeiten allein // da, und aus der Tabelle liess sich nicht ablesen, WANN die Gruppe Gruen // hat - erst recht nicht bei mehreren Fenstern. Die Summenspalte heisst // seitdem auch Summe: "Freigabezeit 40" neben "Freigabefenster 12–40; // 60–72" liesse sonst das erste Fenster fuer die ganze Freigabe halten. 'Freigabefenster [s]', 'Freigabezeit (Summe aller Fenster) [s]', 'Rot [s]', 'Verkehrsstärke [Fz/h]', // Der Anteil mindert die Saettigungsverkehrsstaerke und stand in keiner // Ausgabe; die Kapazitaet daneben war damit nicht herleitbar. // // DIE EINHEIT STEHT IM KOPF, NICHT AM WERT (Fassung 5.10.0): Die Spalte // trug ihre Prozentzahl ohne jeden Massstab, waehrend der Ausdruck an // derselben Stelle "12 %" schreibt (pdf.ts, schwerverkehrZelle) und in // der Nachbarspalte der Auslastungsgrad als Zahl zwischen 0 und 1 steht. // Am Wert waere die Einheit falsch aufgehoben: "12 %" liest eine // Tabellenkalkulation je nach Einstellung als Text oder als 0,12. Die // Klammerform ist die der uebrigen Einheitenspalten dieser Ausgabe. 'Schwerverkehrsanteil [%]', /* * NEU (Schema 13): die Aufteilung des Schwerverkehrs, aus der sich nach * HBS 2015 Gl. 2-5 rechnen laesst. * * DER LEERE WERT IST EINE AUSSAGE und steht deshalb im Spaltennamen: * Leer heisst "nicht gezaehlt" und fuehrt auf Gl. 2-6 mit dem * Pauschalwert, "0" heisst "gezaehlt, keine Lastzuege" und fuehrt auf * Gl. 2-5. Beide ergeben verschiedene Saettigungsverkehrsstaerken; in * einer Auswertung, die die leere Zelle als 0 liest, waeren sie * dieselbe. */ 'Lastzuganteil am Schwerverkehr [%] (leer = nicht erfasst)', /* * NEU (Fassung 5.5.0, Frage E6): der Anpassungsfaktor, * mit dem der Anteil in die Saettigungsverkehrsstaerke eingeht. Zwischen * dem Anteil und der Spalte daneben lag eine Umrechnung, die in keiner * Ausgabe stand - und die Erlaeuterung des Ausdrucks nannte dafuer ein * Pkw-Aequivalent von 2,0, das nachweislich nicht der Wert des HBS ist. * * BERICHTIGT (Fassung 5.10.0): Hier stand "Mit fSV laesst sich die Spalte * nachrechnen: qS = 3600 / (fSV · tB)". Mit fSV allein geht das nicht - * die Spalte "Saettigungsverkehrsstaerke" fuehrt qS0 · n · fA / fSV, also * auch die Fahrstreifenzahl (Spalte "Fahrstreifen") und bei links und * rechts abbiegenden Stroemen die Abminderung fA aus den Vorgaben. Der * Erlaeuterungsabsatz des Ausdrucks nennt die vollstaendige Kette * (bewertung.ts, schwerverkehrSatz); fSV ist der Teil, den die Tabelle * ohne eigene Spalte nicht hergaebe. */ 'Schwerverkehrsfaktor fSV', /* * NEU (Schema 13): nach welcher Gleichung dieser Faktor gebildet wurde. * Seit beide Gleichungen im Einsatz sind, laesst sich fSV aus dem Anteil * allein nicht mehr nachrechnen - zwei Zeilen mit demselben * Schwerverkehrsanteil koennen verschiedene Faktoren tragen. Die Spalte * nennt auch die Annahme, unter der Gl. 2-6 steht (20 % Lastzuege am * Schwerverkehr), und den Fall des verworfenen Eintrags: Ein * unbrauchbarer Lastzuganteil faellt auf Gl. 2-6 zurueck, und das darf * die Datei nicht verschweigen. */ 'Gleichung für fSV (HBS 2015)', 'Sättigungsverkehrsstärke [Fz/h]', // Die Abflusszeit tA ist die Groesse, aus der sich die Kapazitaet // nachrechnen laesst (C = qS · tA/tU). Nach HBS 2015 ist sie tF + 1 s je // Freigabezeitfenster, nach HCM die Freigabezeit selbst - ohne die Spalte // ginge die Kapazitaet neben der Freigabezeit nicht auf (Befund B2). 'Abflusszeit tA [s]', 'Kapazität [Fz/h]', 'Auslastungsgrad', 'Mittlere Wartezeit [s]', // Laengste Sperrzeit im Umlauf. Fuer Fussgaenger und Radverkehr ist sie // nach HBS 2015 das Bewertungskriterium (Befund B4); fuer Kfz und OePNV // eine Kenngroesse, die im Ausdruck ebenfalls steht. 'Maximale Wartezeit [s]', 'Qualitätsstufe', // Woran die Stufe gemessen wurde: mittlere Wartezeit, maximale Wartezeit // oder Ueberlastung (q > C). Ein blosses "F" liesse offen, ob die // Wartezeit oder die Kapazitaet den Ausschlag gab (Befund B1). 'Kriterium der Stufe', // Ein blosses "B" sagt nichts: Dieselbe Wartezeit ergibt nach HBS 2015 // und nach HCM verschiedene Stufen - und seit Fassung 5.4.0 // (Befund B2) auch verschiedene Wartezeiten, weil beide Verfahren // wirklich getrennt gerechnet werden. Benannt wie im Ausdruck. 'Bewertungsverfahren', ], ]; for (const group of project.signalGroups) { const planned = plan.groups.find((g) => g.groupId === group.id); const demand = project.demands.find((d) => d.signalGroupId === group.id); const totals = planned ? aspectTotals(planned, plan.cycleTime) : null; // Fuer Fussgaenger und Radverkehr gibt es weder Kapazitaet noch Stufe nach // HCM: `capacity` ist dann null und `serviceLevel` kann null sein. Beide // Faelle muessen als leere Zelle erscheinen, nicht als Absturz. const delay = planned?.delay ?? null; const level = delay?.serviceLevel ?? null; // Verkehrsstaerke und Schwerverkehrsanteil nur fuer Kfz und OePNV. Fuer // Fussgaenger und Radverkehr kann eine Zahl gespeichert sein (Altdatei aus // der Zeit vor Fassung 5.4.0, Befund B4) - sie geht in keine // Rechnung ein, und der Pruefbericht sagt das. In der Tabelle stuende sie // neben Kapazitaet und Auslastungsgrad, die es fuer diese Gruppen nicht // gibt, und laese sich als Eingangsgroesse. Leer wie jede Groesse, die fuer // die Zeile nicht gebildet wird. const nachfrage = demand && hatSaettigungsverkehrsstaerke(group.mode) ? demand : null; // Anteil, Aufteilung, Faktor und Gleichung an einer Stelle gebildet (seit // Schema 13). Vorher rief diese Datei `schwerverkehrsfaktor(anteil)` selbst auf, // also OHNE die Aufteilung: Bei erfassten Lastzuegen stand die Spalte fSV // neben einer Saettigungsverkehrsstaerke, die zu einem anderen Faktor // gehoert. const schwerverkehr = nachfrage ? schwerverkehrsangabe(nachfrage) : null; const maxVorgabe = hoechstfreigabezeitVorgabe(group); rows.push([ group.name, TRAFFIC_MODE_LABELS[group.mode], // Dieselben Beschriftungen wie am Bildschirm und im PDF. Zuvor standen // hier die Schluessel: "rechts" statt "rechts abbiegend". Ein Abgleich // zwischen Tabelle und Ausdruck stimmte damit Zeile fuer Zeile nicht. MOVEMENT_LABELS[group.movement], fmt.numShort(group.vZul), // Ueber fmt und nicht ueber String (Fassung 5.10.0): Eine gebrochene // Fahrstreifenzahl stand hier als "2.5" mit englischem Punkt, gegen die // Zusage im Kopf dieser Datei. Das Einlesen rundet sie seit der // Fassung 5.10.0 ab und meldet die Ersetzung (schema.ts, // `ganzzahlAbrunden`) - eine zweite Wache und kein Ersatz fuer diese: // `SignalGroup.lanes` traegt im Typ keine Ganzzahlschranke, und die Zelle // hat deutsch zu schreiben, was sie bekommt. Bei ganzen Zahlen aendert // sich nichts: numShort laesst nachlaufende Nullen weg. fmt.numShort(group.lanes), fahrzeugartBeschriftung(group.vehicleClass, plan.defaults), group.mode === 'fuss' ? jaNein(group.blindenzusatz === true) : '', group.mode === 'fuss' ? jaNein(group.reducedMobility) : '', planned ? fmt.numShort(planned.times.redYellow) : '', planned ? fmt.numShort(planned.times.yellow) : '', planned ? fmt.numShort(planned.times.minGreen) : '', // Ohne `planned`: Die Vorgabe ist eine Eingangsgroesse der Gruppe und // kein Ergebnis des Plans - sie steht auch dann, wenn die Gruppe keiner // Phase zugeordnet ist. maxVorgabe === null ? '' : fmt.numShort(maxVorgabe), planned ? freigabefensterZelle(planned.greens, plan.cycleTime) : '', planned ? fmt.numShort(planned.totalGreen) : '', totals ? fmt.numShort(totals.rot) : '', /* * LEER AUCH BEI 0 (Fassung 5.10.0): Ein Datensatz ist noch keine * Zaehlung. Wird in der Signalgruppentabelle allein der * Schwerverkehrsanteil ausgefuellt, legt die Oberflaeche einen Datensatz * mit `volume: 0` an; der Fachkern liest ihn seit Fassung 5.10.0 als * "keine Angabe" (signalPlan.ts, erfassteVerkehrsstaerke; Befunde 12 und * 18), der Bildschirm zeichnet das Feld leer (Befund 58), und der * Ausdruck setzt an allen drei Stellen "–". Bliebe hier "0" stehen, sagte * dieselbe Zeile in zwei Ausgaben zweierlei. * * LEER UND NICHT "–": Dieselbe Schreibweise wie in der Nachbarspalte * "Lastzuganteil ... (leer = nicht erfasst)" und wie bei jeder Groesse, * die diese Ausgabe fuer eine Zeile nicht bildet; ein Gedankenstrich * waere in einer Zahlenspalte Text (siehe die Begruendung zur * Kreuztabelle weiter unten). * * Die Spalten zum Schwerverkehr bleiben dagegen stehen: Der Anteil geht * unabhaengig von der Verkehrsstaerke in die Saettigungsverkehrsstaerke * derselben Zeile ein (signalPlan.ts, groupSaturationFlow), und ohne ihn * waere die daneben gedruckte Zahl nicht mehr herleitbar. */ nachfrage === null || nachfrage.volume <= 0 ? '' : fmt.numShort(nachfrage.volume), // Aus derselben Angabe wie fSV zwei Spalten weiter - nicht noch einmal // aus `nachfrage`, sonst stuende in der einen Spalte der eingetragene // und in der anderen der gerechnete Anteil (bewertung.ts, anteilProzent). schwerverkehr ? schwerverkehr.anteilProzent : '', schwerverkehr ? schwerverkehr.lastzuganteilProzent : '', schwerverkehr ? fmt.numShort(schwerverkehr.fsv, 3) : '', schwerverkehr ? schwerverkehr.gleichungSpalte : '', planned?.capacity ? fmt.numShort(planned.capacity.saturationFlow) : '', planned?.capacity ? fmt.numShort(planned.capacity.abflusszeit, 1) : '', planned?.capacity ? fmt.numShort(planned.capacity.capacity) : '', planned?.capacity?.degreeOfSaturation === undefined ? '' : fmt.numShort(planned.capacity.degreeOfSaturation, 3), delay ? fmt.numShort(delay.averageDelay, 1) : '', delay ? fmt.numShort(delay.maximumDelay, 1) : '', // Nur der Buchstabe: Den Grund fuer ein F traegt die Spalte daneben; in // einer Auswertung soll die Stufe filterbar bleiben. level ? level.grade : '', level ? KRITERIUM_LABELS[level.kriterium] : '', delay ? verfahrenLabel(delay.verfahren) : '', ]); } // Rotzeit, Kapazitaet und Auslastung haengen an der Umlaufzeit - deshalb // hier neben dem Warnungsvermerk auch der Ersatzwert-Vermerk (Befund C11). rows.push(...csvVermerke(plan, kontext)); return toCsv(rows); } /** * Was in einer Zelle der Herkunftsmatrix steht. * * Die Diagonale bleibt leer: Eine Signalgruppe gegen sich selbst ist kein * Sachverhalt, ueber dessen Herkunft sich etwas sagen liesse. */ const HERKUNFT_VERTRAEGLICH = 'verträglich'; /** * Zwischenzeitenmatrix als Kreuztabelle, gefolgt von der Herkunftsmatrix. * * WARUM ZWEI TABELLEN (Fassung 5.8.0): Der Ausdruck unterscheidet drei * Sachverhalte, die diese Ausgabe bis dahin auf zwei Darstellungen zusammenzog. * * - Der Ausdruck setzt hinter eine von Hand vorgegebene Zwischenzeit einen * Stern. Hier stand dieselbe Zahl ohne Kennzeichnung: Wer die Datei * auswertete, sah nicht, welche Werte die Berechnung ersetzt hatten - und * genau das ist der Nachweis, den eine Pruefstelle sucht. * - Die leere Zelle stand fuer ZWEI Dinge: die Diagonale und ein * vertraegliches Paar ohne erfasste Konfliktbeziehung. Im Ausdruck ist das * erste leer und das zweite ein Punkt. Diese Ausgabe ist zugleich die * einzige maschinell lesbare Quelle dafuer, WELCHE Paare vertraeglich * sind - der Rechenweg (intergreenDetailCsv) fuehrt nur die erfassten * Beziehungen. * * WARUM NICHT DER STERN WIE IM AUSDRUCK: "7*" ist keine Zahl mehr. Eine * Tabellenkalkulation liest die Zelle als Text, und in einer Spalte, in der * einzelne Werte vorgegeben sind, stuenden Zahl und Text gemischt - Summen, * Hoechstwerte und Vergleiche schluegen dort fehl. Die Kreuztabelle wird * gerechnet; das ist ihr Zweck und der Unterschied zum Ausdruck, der gelesen * wird. Sie bleibt deshalb rein numerisch, und die Kennzeichnung steht in * einer zweiten Kreuztabelle gleichen Zuschnitts darunter. Beide stehen in * DERSELBEN Datei, weil die Oberflaeche Matrix und Rechenweg als getrennte * Schaltflaechen anbietet: Wer nur die Matrix laedt, haette den Nachweis sonst * nicht. * * Die Vermerke bleiben die letzten Zeilen der Datei (Befund C7) - die zweite * Tabelle steht davor. */ export function intergreenCsv( project: Project, plan: SignalPlan, kontext?: CsvPruefkontext, ): string { const groups = project.signalGroups; const kopf = ['räumt \\ fährt ein', ...groups.map((g) => g.name)]; const rows: string[][] = [kopf]; for (const from of groups) { rows.push([ from.name, ...groups.map((to) => { if (from.id === to.id) return ''; const resolved = plan.intergreens.get(intergreenKey(from.id, to.id)); return resolved ? fmt.numShort(resolved.value, 0) : ''; }), ]); } /* * Leerzeile als Trenner: Eine Tabellenkalkulation erkennt daran das Ende des * ersten Blocks, und beim Lesen mit dem Auge steht der Kopf der zweiten * Tabelle nicht unmittelbar unter der letzten Wertezeile. * * Die zweite Tabelle bekommt DIESELBE Kopfzeile wie die erste. Damit liegen * beide Bloecke Zelle auf Zelle uebereinander - wer sie verknuepft, braucht * keine Zuordnung, nur denselben Zeilen- und Spaltenschluessel. Die * Titelzeile darueber sagt, was der Block enthaelt, und erklaert die leere * Zelle: Sie waere sonst die einzige Angabe, die sich nicht von selbst * versteht. */ rows.push([]); rows.push(['Herkunft der Zwischenzeit – leere Zelle: dieselbe Signalgruppe']); rows.push(kopf); for (const from of groups) { rows.push([ from.name, ...groups.map((to) => { if (from.id === to.id) return ''; const resolved = plan.intergreens.get(intergreenKey(from.id, to.id)); return resolved ? resolved.source : HERKUNFT_VERTRAEGLICH; }), ]); } // Zwischenzeiten haengen nicht an der Umlaufzeit - nur der Warnungsvermerk. rows.push(...csvVermerke(null, kontext)); return toCsv(rows); } /** Zwischenzeiten mit vollstaendigem Rechenweg. */ export function intergreenDetailCsv( project: Project, plan: SignalPlan, kontext?: CsvPruefkontext, ): string { const rows: string[][] = [ [ 'Räumende SG', 'Einfahrende SG', 'Räumweg [m]', 'Fahrzeuglänge [m]', 'Räumweg gesamt [m]', 'Räumgeschwindigkeit [m/s]', /* * NEU (Fassung 5.5.0, Frage E1): das Merkmal der einzelnen * Konfliktbeziehung, das die Raeumgeschwindigkeit daneben senkt. Ohne die * Spalte stuenden in derselben Tabelle 5,0 und 7,0 m/s nebeneinander, * ohne dass der Unterschied erklaerbar waere. Die Zelle nennt die volle * Beschriftung samt Vorbehalt (bewertung.ts, engerRadiusZelle) - eine * Tabellenausgabe hat keine Fussnote, in der er sonst stuende. */ 'Enger Innenradius', // Sagt, wie die Raeumzeit aus den Spalten daneben entsteht: im Regelfall // Raeumweg gesamt / Raeumgeschwindigkeit, beim OePNV mit Halt vor dem // Knotenpunkt der Anfahransatz der RiLSA (Fall 4) - dort ist die // Raeumgeschwindigkeit nur die Obergrenze beim Beschleunigen. 'Räumansatz', 'Räumzeit [s]', 'Einfahrweg [m]', 'Einfahrgeschwindigkeit [m/s]', 'Einfahrzeit [s]', 'Überfahrzeit [s]', /* * NEU (Fassung 5.5.0, Frage E2): woher die Ueberfahrzeit daneben stammt. Bei * Kraftfahrzeugen ist das der gewaehlte Rechenansatz - er gilt fuer den * ganzen Plan, steht aber in jeder Zeile, weil die Tabellenausgabe zum * Auswerten und zum Zusammenfuehren mehrerer Plaene gedacht ist und eine * Angabe, die nur einmal am Ende stuende, dabei verlorenginge. Dieselbe * Beschriftung wie am Bildschirm und im Ausdruck. * * Bei den uebrigen Verkehrsarten steht deren eigene Herleitung da * (Fassung 5.5.0, Befund F5): Der Kfz-Ansatz erreicht sie nicht, und seine Beschriftung * neben einer Fussgaenger-Ueberfahrzeit war schlicht falsch. Die Spalte * heisst deshalb "Herkunft" und nicht mehr "Ansatz". */ 'Überfahrzeit-Herkunft', 'Rechenwert [s]', 'Zwischenzeit [s]', // "Herkunft" allein war zweideutig: Die Spalte sagt, ob der ZWISCHENZEIT- // wert gerechnet oder vorgegeben ist - nicht, woher Raeum- und Einfahrweg // stammen. Im PDF steht daneben eine Spalte "Herkunft der Wege"; wer // beide Ausgaben nebeneinanderlegte, hielt "berechnet" fuer eine Aussage // ueber die Vermessung. 'Herkunft der Zwischenzeit', 'Herkunft der Wege', 'Bemerkung', ], ]; for (const conflict of project.conflicts) { const from = project.signalGroups.find((g) => g.id === conflict.fromId); const to = project.signalGroups.find((g) => g.id === conflict.toId); const resolved = plan.intergreens.get(intergreenKey(conflict.fromId, conflict.toId)); if (!from || !to || !resolved) continue; const calc = resolved.calculation; rows.push([ from.name, to.name, fmt.numShort(conflict.clearingDistance, 2), calc ? fmt.numShort(calc.vehicleLength, 2) : '', calc ? fmt.numShort(calc.clearingPath, 2) : '', calc ? fmt.numShort(calc.clearingSpeed, 2) : '', // Das Merkmal steht auch dann da, wenn die Zwischenzeit von Hand // vorgegeben ist (calc === null): Es ist eine Angabe ueber die // Oertlichkeit und keine Zwischengroesse der Rechnung. engerRadiusZelle(conflict.engerRadius, 'nein'), calc ? calc.raeumansatz === 'anfahren' ? `Anfahren nach Halt (RiLSA Fall 4), a = ${fmt.num(calc.anfahrbeschleunigung ?? 0, 1)} m/s²` : 'konstante Geschwindigkeit' : '', calc ? fmt.numShort(calc.clearingTime, 3) : '', fmt.numShort(conflict.enteringDistance, 2), calc ? fmt.numShort(calc.enteringSpeed, 2) : '', calc ? fmt.numShort(calc.enteringTime, 3) : '', calc ? fmt.numShort(calc.crossingTime, 2) : '', // Leer, wo keine Ueberfahrzeit gerechnet wurde: Bei vorgegebener // Zwischenzeit hat der Ansatz nichts bestimmt, und eine Angabe daneben // behauptete das Gegenteil. // // KORREKTUR (Fassung 5.5.0, Befund F5): Hier stand die Beschriftung des // Kfz-Ansatzes in JEDER Zeile - auch neben der 0,00 s eines // Fussgaengerstroms und neben den 5 s eines OePNV-Stroms aus der // Vmax-Staffel. Der Ansatz gilt ausschliesslich fuer Kraftfahrzeuge. // Woher die Ueberfahrzeit je Verkehrsart stammt, entscheidet jetzt der // Fachkern (ansaetze.ts, ueberfahrzeitHerkunft). // // KORREKTUR (Fassung 5.9.0): Diese Aufrufstelle gab nur `vorgegeben` mit. // Eine von resolveCrossingTime VERWORFENE Handeingabe - etwa 0 s neben // einer Gelbzeit von 3 s - wies die Spalte weiter als "an dieser // Beziehung von Hand eingetragen" aus, waehrend die Spalte links davon // den Regelwert zeigte. Wie in conflictsView.ts gehen deshalb der // Vorgabewert und der angesetzte Wert mit; entschieden wird im Fachkern. // // KORREKTUR (Fassung 5.43.0): Das VERFAHREN geht mit. An einer // einstreifigen Verkehrsfuehrung steht in der Spalte "Ueberfahrzeit [s]" // links davon die feste Zahl des Abschnitts 5.2.2 - 4 s -, und daneben // stand die Beschriftung des Kfz-Ansatzes, der dort nichts bestimmt. calc ? ueberfahrzeitHerkunft(calc.verfahren, from.mode, project.settings.ueberfahrzeitAnsatz, { haltVorKnoten: conflict.haltVorKnoten === true, vorgegeben: conflict.crossingTimeOverride !== null, ...(conflict.crossingTimeOverride !== null ? { vorgabewert: conflict.crossingTimeOverride } : {}), angesetzt: calc.crossingTime, }) : '', calc ? fmt.numShort(calc.raw, 3) : '', fmt.numShort(resolved.value, 0), resolved.source, // Wie im PDF zwei getrennte Angaben: Die Herkunft der Wege fuehrt das // Programm selbst, die Bemerkung ist freier Text und kann aelter sein als // der Wert neben ihr. wegherkunftZelle(conflict), conflict.note, ]); } // Zwischenzeiten haengen nicht an der Umlaufzeit - nur der Warnungsvermerk. rows.push(...csvVermerke(null, kontext)); return toCsv(rows); } /** * Pruefbericht als Tabelle. * * Bewusst OHNE Pruefkontext und ohne Sperre (Befund C7): Der Pruefbericht ist * keine Planunterlage, sondern das Dokument der Fehler - er muss immer * ausgebbar sein, gerade wenn der Export gesperrt ist. */ export function reportCsv(report: ValidationReport): string { const rows: string[][] = [ ['Art', 'Bereich', 'Beanstandung', 'Beschreibung', 'Zu tun', 'Fundstelle'], ]; for (const finding of report.findings) { rows.push([ SEVERITY_LABELS[finding.severity], CATEGORY_LABELS[finding.category], finding.title, finding.message, finding.suggestion, finding.reference, ]); } return toCsv(rows); } /** Phasen mit Freigabezeiten und Uebergaengen. */ export function phasesCsv(project: Project, plan: SignalPlan, kontext?: CsvPruefkontext): string { const rows: string[][] = [ ['Nr.', 'Phase', 'Signalgruppen', 'Beginn [s]', 'Freigabezeit [s]', 'Übergangszeit danach [s]'], ]; plan.phases.forEach((phase, index) => { const transition = plan.transitions[index]; rows.push([ String(index + 1), phase.name, phase.signalGroupIds .map((id) => project.signalGroups.find((g) => g.id === id)?.name ?? '?') .join(', '), fmt.numShort(phase.start, 1), fmt.numShort(phase.duration, 1), transition ? fmt.numShort(transition.duration, 1) : '', ]); }); // Beginn und Dauer der Phasen sind auf die Umlaufzeit verteilt - deshalb // hier neben dem Warnungsvermerk auch der Ersatzwert-Vermerk (Befund C11). rows.push(...csvVermerke(plan, kontext)); return toCsv(rows); }