diff --git a/Doku/ANLEITUNG.md b/Doku/ANLEITUNG.md
index a563649..ddf52ba 100644
--- a/Doku/ANLEITUNG.md
+++ b/Doku/ANLEITUNG.md
@@ -14,7 +14,7 @@ AndiGenerator.NET ist die Weiterentwicklung des **AndiGenerators** von Andreas H
6. [Pläne beurteilen: die Ansichten](#6-pläne-beurteilen-die-ansichten)
7. [Gewichtungen anpassen](#7-gewichtungen-anpassen)
8. [Pläne merken und vergleichen](#8-pläne-merken-und-vergleichen)
-9. [Export, Excel und Druck](#9-export-excel-und-druck)
+9. [Export und Druck](#9-export-und-druck)
10. [Spielplandaten bearbeiten](#10-spielplandaten-bearbeiten)
11. [Runden: Doppelrunde, Halbrunde, Vor- und Rückrunde einzeln](#11-runden)
12. [Bewertung der Pläne (Kosten)](#12-bewertung-der-pläne-kosten)
@@ -94,6 +94,7 @@ click-TT überträgt nicht alles, was für einen guten Plan nötig ist, und die
| Punkt | Worum es geht |
|---|---|
| **Gespeicherte Einstellungen** | Sie haben die Staffel schon einmal bearbeitet und dabei Gewichtungen geändert. Wählen Sie, ob Sie diese weiterverwenden oder für diese Sitzung mit den Standardeinstellungen rechnen wollen (die gespeicherten bleiben erhalten). |
+| **Gespeicherte Kriterienreihenfolge** | Sie haben für diese Staffel in der Ansicht **Qualität** eine eigene Einteilung der Kriterien in die Stufen A, B und C festgelegt (danach richtet sich der Automodus). Die Seite zeigt sie; wählen Sie, ob Sie sie weiterverwenden oder für diese Sitzung die Standardreihenfolge nehmen (die gespeicherte bleibt erhalten, bis Sie die Einteilung wieder ändern). |
| **Rundenplanung** | Ist die Runde kürzer als sechs Monate, schlägt das Programm eine Halbrunde vor. Liegt die Vorrunde schon in der Vergangenheit, schlägt es vor, nur die Rückrunde zu planen. |
| **Spiellokale** | click-TT überträgt keine Spiellokale. Haben Vereine mehrere Hallen, tragen Sie hier ein, welche Mannschaft wo spielt – sonst stimmt die Berechnung der Hallenbelegung nicht. Haben alle Vereine nur eine Halle, können Sie den Punkt überspringen. |
| **Koppeltermine** | Die Staffel enthält Koppelwünsche oder mögliche Doppelspieltage. Das Programm leitet sie aus den Meldungen ab; bitte kontrollieren. |
@@ -101,7 +102,7 @@ click-TT überträgt nicht alles, was für einen guten Plan nötig ist, und die
| **Setzliste** | Wenn Sie mit einer Setzliste arbeiten wollen, geben Sie hier die erwartete Abschlusstabelle vor. |
| **Pflichtspieltage** | Die Staffel enthält Pflichtspieltage. Sie sind bei ungerader Mannschaftszahl problematisch (siehe [Pflichtspieltage](#pflichtspieltage)). |
-Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung, **Abschließen und generieren** startet zusätzlich gleich die Generierung. Offene Punkte werden dabei übersprungen. Alles lässt sich später unter **Spielplandaten** und **Einstellungen** ändern.
+Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung; die Generierung startet danach im Hauptfenster mit **Kostenoptimierung starten** bzw. **Automodus starten**, wo auch der Ausgangsplan gewählt wird. Bei Entscheidungen gilt dabei die angeklickte Wahl, auch ohne **Auswahl übernehmen**; Punkte, bei denen Sie nichts gewählt haben, bleiben unverändert. Die Wahlkacheln und Knöpfe stehen immer unten im Blick, auch wenn die Erklärung darüber lang ist. Alles lässt sich später unter **Spielplandaten**, **Einstellungen** und in der Ansicht **Qualität** ändern.
### 3.4 Terminwünsche kontrollieren
@@ -109,7 +110,14 @@ Bevor Sie die Generierung starten, lohnt ein Blick in die Ansicht **Terminwünsc
### 3.5 Generierung starten und beobachten
-Mit **Generierung starten** oben rechts beginnt die Suche. Das Programm rechnet mit allen Prozessorkernen bis auf einen, damit der Rechner bedienbar bleibt, und zeigt laufend den besten bisher gefundenen Plan.
+Mit dem blauen Startknopf oben rechts beginnt die Suche. Über den Pfeil rechts daneben wählen Sie das Verfahren:
+
+- **Kostenoptimierung** – wie im Original: Die Suche senkt die Gesamtkosten mit Ihren Gewichtungen.
+- **Automodus** – die Suche lenkt sich selbst über die Gewichte, so wie man es von Hand an den Reglern tun würde: Erst drängt sie Verstöße der Stufen A und B heraus (Hallenbelegung, parallele Spiele, Pflichtspieltage, Koppel-, Sperr- und Ausweichtermine), dann glättet sie die übrigen Kriterien, ohne A und B wieder zu verschlechtern. Die erste Kachel zeigt dann statt der Gesamtkosten die Verstöße A, B und C des besten Plans (ein Klick öffnet die Ansicht **Qualität**; die Kachel *Pflichtregeln* entfällt), oben die Phase des Automodus (**Basisoptimierung** mit Ihren Gewichtungen, dann **Optimierung Stufe A**, **Stufe B** und **Stufe C**), unten nur Hinweise wie „angehalten“ oder verbliebene harte Fehler. Wechseln Sie während der Generierung von der Kostenoptimierung in den Automodus, entfällt die Basisoptimierung: Der bisher beste Plan ist schon optimiert und gilt als Basis, der Automodus beginnt sofort mit Stufe A. Beim Start des Automodus (und beim Wechsel zu ihm) springt das Programm in die Ansicht **Qualität**, beim Start der Kostenoptimierung (und beim Wechsel zu ihr) in die Ansicht **Kosten**; fehlt die Ansicht, wird sie geöffnet.
+
+Der Knopf merkt sich die Wahl und heißt danach **Kostenoptimierung starten** bzw. **Automodus starten**. Auch während der Generierung – laufend oder pausiert – lässt sich über den Pfeil zum anderen Verfahren wechseln; die Suche setzt beim bisher besten Plan fort.
+
+Das Programm rechnet mit allen Prozessorkernen bis auf einen, damit der Rechner bedienbar bleibt, und zeigt laufend den besten bisher gefundenen Plan.
Schon nach wenigen Sekunden steht ein erster Plan. Nach einigen hunderttausend berechneten Plänen ist meist etwas Brauchbares dabei; nach einigen Millionen findet das Programm oft nur noch kleine Verbesserungen. Wie schnell das geht, hängt stark vom Prozessor ab – die Anzeige **Berechnete Pläne** nennt die aktuelle Geschwindigkeit.
@@ -117,7 +125,7 @@ Schon nach wenigen Sekunden steht ein erster Plan. Nach einigen hunderttausend b
Während die Generierung läuft, beurteilen Sie den Plan in den Ansichten **Kosten**, **Qualität** und **Diagramme** und steuern über die Gewichtungen nach (Abschnitt [7](#7-gewichtungen-anpassen)). Die Generierung muss dafür nicht angehalten werden.
-Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT exportieren** als CSV-Datei (Abschnitt [9](#9-export-excel-und-druck)). Die CSV-Datei importieren Sie in click-TT als Spielplan.
+Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT exportieren** als CSV-Datei (Abschnitt [9](#9-export-und-druck)). Die CSV-Datei importieren Sie in click-TT als Spielplan.
## 4. Das Hauptfenster
@@ -127,20 +135,24 @@ Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT export
- **Staffel:** Öffnen, Neu, Spielplandaten, Einstellungen
- **Ansichten:** Kosten, Qualität, Meldungen, Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften
-- **Ausgabe:** Für click-TT exportieren, Drucken (PDF)
+- **Ausgabe:** Für click-TT exportieren (farblich hervorgehoben), Drucken (PDF)
- **Anleitung:** öffnet diese Anleitung als PDF
- **Über:** Version, Urheber und Lizenzen
-**Oben** stehen der Name der Staffel, die Datei und die Steuerung der Generierung: **Start mit**, **Plan merken**, **Generierung starten / Pausieren / Fortsetzen**, **Beenden** und **Schließen**. *Schließen* führt zur Startseite zurück; läuft noch eine Generierung, fragt das Programm vorher nach.
+Die Abschnitte *Staffel*, *Ansichten* und *Ausgabe* lassen sich mit einem Klick auf ihre Überschrift zu- und wieder aufklappen; so bleibt die Leiste auch auf kleinen Bildschirmen übersichtlich.
+
+**Oben** stehen der Name der Staffel, die Datei und die Steuerung der Generierung: **Start mit**, **Plan merken**, **Kostenoptimierung starten / Automodus starten / Pausieren / Fortsetzen** (mit dem Pfeil zur Wahl des Verfahrens), **Beenden** und **Schließen**. *Schließen* führt zur Startseite zurück; läuft noch eine Generierung, fragt das Programm vorher nach. Wählen Sie unter **Start mit** einen vorhandenen Plan (gemerkt oder aus click-TT), zeigen alle Ansichten und die Kacheln sofort diesen Plan – mit Kosten und Qualität –, ohne dass eine Optimierung startet und ihn verändert.
Darunter vier **Kacheln** mit dem Stand der Generierung:
| Kachel | Bedeutung |
|---|---|
| **Gesamtkosten (bester Plan)** | Die Kosten des besten bisher gefundenen Plans; darunter, wie stark sie seit Beginn gesunken sind. Nach einer Änderung der Gewichtung gilt der Vergleich ab dieser Änderung, weil die Kosten davor anders berechnet wurden. **Details ›** öffnet die Kostenansicht. |
+| **Pflichtregeln (Stufe A)** | Ob der beste Plan die wichtigsten Regeln einhält (siehe [Qualität](#62-qualität)). **Details ›** öffnet die Qualitätsansicht. |
| **Verbesserungen** | Wie oft ein besserer Plan gefunden wurde, und wann zuletzt. |
| **Berechnete Pläne** | Anzahl der geprüften Pläne und die aktuelle Geschwindigkeit. |
-| **Pflichtregeln (Stufe A)** | Ob der beste Plan die wichtigsten Regeln einhält (siehe [Qualität](#62-qualität)). **Details ›** öffnet die Qualitätsansicht. |
+
+Im Automodus entfällt die Kachel *Pflichtregeln*; die Ergebniskachel nimmt ihren Platz mit ein, *Verbesserungen* und *Berechnete Pläne* bleiben an ihrer Stelle. Die breitere Ergebniskachel zeigt oben die Phase (z. B. „Automodus · Optimierung Stufe A“) und unten unter **In Arbeit** alle Kriterien, an denen gerade gearbeitet wird (in Stufe A und B die zuletzt angehobenen, in C das, das geglättet wird) und vor jeder Verstoßzahl klein den Stand am Ende der Basisoptimierung (z. B. „A 14 → 3“).
Den **Hauptbereich** füllen die Ansichten. Jede Ansicht öffnet sich als Reiter; Sie können mehrere öffnen, zwischen ihnen wechseln, sie per Maus nebeneinander anordnen und mit dem **×** am Reiter schließen. In vielen Ansichten vergrößert oder verkleinert **Strg + Mausrad** (oder der Zoomregler) die Darstellung.
@@ -150,7 +162,8 @@ Ganz unten zeigt die **Statuszeile** den Stand in einer Zeile.
| Schaltfläche | Wirkung |
|---|---|
-| **Generierung starten** | Beginnt die Suche mit dem Ausgangsplan aus **Start mit**. |
+| **Kostenoptimierung starten** / **Automodus starten** | Beginnt die Suche mit dem Ausgangsplan aus **Start mit** und dem gewählten Verfahren. |
+| **Pfeil neben dem Startknopf** | Wählt das Verfahren; während der Generierung wechselt er zum anderen Verfahren, ab dem bisher besten Plan. |
| **Pausieren** | Hält die Suche an. Der beste Plan bleibt erhalten; so können Sie ihn in Ruhe ansehen oder exportieren. |
| **Fortsetzen** | Setzt eine pausierte Suche mit ihrem besten Plan fort. |
| **Beenden** | Beendet die Generierung. Danach kann mit einem anderen Ausgangsplan neu gestartet werden. |
@@ -168,7 +181,7 @@ Solange eine Generierung läuft oder pausiert ist, ist *Start mit* gesperrt: Ein
## 6. Pläne beurteilen: die Ansichten
-Die Ansichten **Kosten, Qualität, Meldungen, Terminplan** und **Diagramme** zeigen einen Plan. Welchen, wählen Sie oben in der Ansicht unter **Plan**: den besten Plan der laufenden Generierung, den in click-TT vorhandenen Plan oder einen gemerkten Plan. So lassen sich Pläne nebeneinander vergleichen – etwa zwei Kostenansichten mit verschiedenen Plänen. Gemerkte Pläne löschen Sie dort mit **Löschen**; **Nach Excel** speichert den gewählten Plan als Excel-Arbeitsmappe.
+Die Ansichten **Kosten, Qualität, Meldungen, Terminplan** und **Diagramme** zeigen einen Plan. Welchen, wählen Sie oben in der Ansicht unter **Plan**: den besten Plan der laufenden Generierung, den in click-TT vorhandenen Plan oder einen gemerkten Plan. So lassen sich Pläne nebeneinander vergleichen – etwa zwei Kostenansichten mit verschiedenen Plänen.
Die Ansichten **Terminwünsche** und **Nachbarmannschaften** hängen nicht vom Plan ab, sondern nur von den Daten der Staffel.
@@ -195,15 +208,19 @@ Die aufklappbare Hilfe **So funktioniert die Tabelle** unter der Matrix fasst da

-Die Kosten sagen, wie gut ein Plan *insgesamt* ist. Die Qualitätsansicht beantwortet eine andere Frage: **Welche Regeln verletzt der Plan, und wie oft?** Die Kriterien sind nach Wichtigkeit in drei Stufen geordnet:
+Die Kosten sagen, wie gut ein Plan *insgesamt* ist. Die Qualitätsansicht beantwortet eine andere Frage: **Welche Regeln verletzt der Plan, und wie oft?** Die Kriterien sind nach Wichtigkeit in drei Stufen geordnet. Voreingestellt ist:
| Stufe | Kriterien | Ziel |
|---|---|---|
-| **A – Pflicht** | A1 alle Spiele terminiert, gültig und nicht an spielfreien Tagen · A2 Hallenbelegung · A3 parallele Spiele · A4 vereinsinterne Spiele am Anfang · A5 Pflichtspieltage | einhalten |
-| **B – Wünsche** | B1 Sperrtermine · B2 Ausweichtermine | möglichst wenige Verstöße; bei sehr vielen gemeldeten Sperrterminen ist ein Rest vertretbar |
-| **C – Güte** | alle übrigen Kostenarten, z. B. Wechsel Heim/Auswärts, 3-Tage-Abstand, Überlappung der Spieltage | so gut wie möglich |
+| **A – Pflicht** | A1 alle Spiele terminiert, gültig und nicht an spielfreien Tagen · A2 Hallenbelegung · A3 parallele Spiele · A4 Pflichtspieltage · A5 vereinsinterne Spiele am Anfang | einhalten |
+| **B – Wünsche** | B1 Auswärtskoppel · B2 Heimkoppel · B3 Sperrtermine · B4 Ausweichtermine | möglichst wenige Verstöße; bei sehr vielen gemeldeten Sperrterminen ist ein Rest vertretbar |
+| **C – Güte** | alle übrigen Kostenarten, z. B. Heim/Auswärts ausgeglichen, Wechsel Heim/Auswärts, 3-Tage-Abstand, zuletzt die Spieltage | so gut wie möglich |
+
+**Verstöße im Einzelnen:** Ein Klick auf eine Zeile zeigt rechts daneben, wo das Kriterium verletzt ist – je Mannschaft, die meisten Verstöße zuerst, mit den Meldungen wie in der Ansicht **Meldungen** (bei A1 die harten Fehler des ganzen Plans). Ein zweiter Klick auf die Zeile oder **×** schließt die Liste. Hat das Kriterium keine Verstöße, bleibt sie zu; die Liste folgt dem Plan, auch während die Generierung läuft. Länge und Überlappung der Spieltage und vereinsinterne Spiele haben keine Einzelangaben.
+
+**Die Einteilung bestimmen Sie selbst (im Automodus):** Rechts in jeder Zeile wählen Sie die Stufe **A**, **B** oder **C** und verschieben das Kriterium mit den Pfeilen nach oben (wichtiger) oder unten. Rückt ein Kriterium in eine weniger wichtige Stufe (A → B, B → C), steht es dort an erster Stelle; rückt es in eine wichtigere (C → B, B → A), kommt es ans Ende. Am Rand einer Stufe führen die Pfeile in die Nachbarstufe. Oder Sie ziehen die Zeile am Griff **⋮⋮** links an die gewünschte Stelle: Die Zeile hängt dann am Mauszeiger, und die Tabelle macht dort Platz, wo sie landen würde, und nummeriert schon um – Stufe und Nummer ergeben sich aus der Stelle. **Esc** bricht das Ziehen ab. Bei der Kostenoptimierung ist die Einteilung nur zu sehen, nicht zu ändern. Die Nummern folgen der Reihenfolge. Nur A1 (harte Fehler) ist fest, und Länge und Überlappung der Spieltage bleiben in C, weil sie keine Anzahl von Verstößen haben. Die Einteilung wird mit den Spielplandaten gespeichert (bei click-TT-Dateien in der `.modifications`; der alte AndiGenerator lädt die Datei weiterhin und lässt die Angabe unbeachtet). Der **Automodus** richtet sich nach dieser Einteilung: Er drängt erst die Verstöße der Stufe A, dann B heraus und glättet C in der gewählten Reihenfolge. Eine Änderung während der Generierung gilt sofort, ohne neue Basisoptimierung: Der Automodus behält seine Gewichte und macht sofort beim wichtigsten geänderten Kriterium weiter – schieben Sie etwa B1 nach A2, drängt er zuerst genau dieses Kriterium heraus (solange es dort noch vorangeht), danach wieder die ganze Stufe A; bei einer Änderung in C glättet er ab diesem Kriterium. Liegt die Änderung hinter der Stelle, an der er gerade arbeitet, macht er dort einfach weiter.
-Rot markiert sind verletzte Pflichten der Stufe A. Die Kachel **Pflichtregeln (Stufe A)** im Kopf zeigt denselben Stand für die laufende Generierung.
+Die Spalte **Kosten** erscheint nur bei der Kostenoptimierung. Rot markiert sind verletzte Pflichten der Stufe A. Die Kachel **Pflichtregeln (Stufe A)** im Kopf zeigt denselben Stand für die laufende Generierung.
### 6.3 Meldungen
@@ -300,29 +317,23 @@ Um Pläne aus verschiedenen Einstellungen zu vergleichen, merken Sie sich Zwisch
1. **Plan merken** (oben) anklicken – gemerkt wird der beste Plan der laufenden Generierung.
2. Einen Namen eingeben, z. B. „Sperrtermine hoch“.
-Der Plan erscheint danach in jeder Ansicht in der Auswahl **Plan** und unter **Start mit**. Sie können ihn so ansehen, mit dem aktuellen vergleichen, als Ausgangsplan für eine neue Generierung nehmen oder mit **Löschen** entfernen.
+Der Plan erscheint danach in jeder Ansicht in der Auswahl **Plan** und unter **Start mit**. Sie können ihn so ansehen, mit dem aktuellen vergleichen, als Ausgangsplan für eine neue Generierung nehmen oder entfernen: Ist er vor dem Start unter **Start mit** gewählt, steht oben an der Stelle von *Plan merken* der Knopf **Plan löschen**.
Gemerkte Pläne liegen im Datenordner der Staffel und stehen auch nach einem Neustart zur Verfügung.
-## 9. Export, Excel und Druck
+## 9. Export und Druck
### 9.1 Für click-TT exportieren
-**Für click-TT exportieren** speichert den **besten Plan der Generierung** als CSV-Datei, die click-TT als Spielplan importiert. Pausieren Sie die Generierung vorher, damit sich der Plan nicht mehr ändert.
+**Für click-TT exportieren** (linke Leiste, *Ausgabe*, farblich hervorgehoben) speichert den **gerade angezeigten Plan** als CSV-Datei, die click-TT als Spielplan importiert: den Plan, den die aktive Ansicht zeigt (laufende Generierung, click-TT-Plan oder ein gemerkter Plan), sonst den Stand der Generierung bzw. den unter *Start mit* gewählten Plan. Welcher es ist, verrät der Tooltip. Gibt es keinen Plan, ist der Eintrag grau und nicht anklickbar. Exportieren Sie den laufenden Plan, pausieren Sie die Generierung vorher, damit er sich nicht mehr ändert.
Vor dem Export prüft das Programm auf harte Fehler: Spiele ohne Termin, Spiele mit ungültigem Termin und Spiele an spielfreien Tagen. Gibt es welche, werden sie aufgelistet, und Sie entscheiden, ob Sie trotzdem exportieren. Spiele ohne Termin stehen in der CSV-Datei am ersten Tag der Runde um 00:00 Uhr.
Die Spiellokale werden mit exportiert. Sie müssen dafür Nummern von 1 bis 5 sein, so wie click-TT sie erwartet.
-> **Einen gemerkten Plan exportieren:** Wählen Sie ihn unter **Start mit**, starten Sie die Generierung und pausieren Sie sofort. Der gemerkte Plan ist dann der beste Plan und wird exportiert.
-
Wurde nur die Vor- oder nur die Rückrunde geplant, enthält die CSV-Datei auch nur deren Spiele.
-### 9.2 Nach Excel
-
-**Nach Excel** in den Plan-Ansichten speichert den dort gewählten Plan als Excel-Arbeitsmappe mit den Blättern *Übersicht*, *Spielplan*, *Mannschaftspläne* und *Kosten*. Spielplan und Mannschaftspläne sind filterbare Listen mit echten Datums- und Zeitwerten; die Kostentabelle ist eingefärbt wie in der Kostenansicht. So lässt sich ein Plan mit anderen teilen oder weiter auswerten.
-
-### 9.3 Drucken (PDF)
+### 9.2 Drucken (PDF)

diff --git a/Doku/MIGRATIONSPLAN.md b/Doku/MIGRATIONSPLAN.md
index fa03813..de9b890 100644
--- a/Doku/MIGRATIONSPLAN.md
+++ b/Doku/MIGRATIONSPLAN.md
@@ -578,7 +578,36 @@ Die **TT-Spielplan Engine** (Stephan Rüb, v1.1.2) ist ein neues, unabhängiges
**Merkposten:**
1. **Oberflächenkonzepte** (Kandidaten für Phase 4/5): Top-5-Pläne mit „ab hier optimieren“; Planqualität als Zahl der Verstöße je Kriterium (live, Ziel 0; passt zu E14); Diagramme (Heim-/Auswärts-Rhythmus je Mannschaft, Spiel- und Pausenrhythmus, Auslastung je Kalenderwoche); Gewichtungs-Voreinstellungen; Konfliktliste; Terminwünsche mit Erfüllungsstatus; Kalender mit Verschieben per Klick; Verlauf früherer Läufe; Batch-Modus für mehrere Staffeln; Importe (Rahmenterminplan-PDF, Vereinsbemerkungen-PDF, Setzliste aus TTR-CSV); Plausibilitätsprüfung der Daten vor dem Start; geführte Einführung (Tour).
-2. **Andere Optimierungsverfahren prüfen**, insbesondere **Google OR-Tools CP-SAT** (Apache-2.0, GPL-v3-kompatibel; .NET-Paket `Google.OrTools`, nativ ca. 35 MB): z. B. als Startlösung oder für Teilprobleme (Paarungen/Heimrecht, Pflichtspielwoche) in Kombination mit der bestehenden lokalen Suche. Maßstab bleibt das Kostenmodell des Originals (`andigen bewerten`).
+2. **Andere Optimierungsverfahren prüfen**, insbesondere **Google OR-Tools CP-SAT** (Apache-2.0, GPL-v3-kompatibel; .NET-Paket `Google.OrTools`, nativ ca. 35 MB): z. B. als Startlösung oder für Teilprobleme (Paarungen/Heimrecht, Pflichtspielwoche) in Kombination mit der bestehenden lokalen Suche. Maßstab bleibt das Kostenmodell des Originals (`andigen bewerten`). Ausgearbeitet in **Abschnitt 11** (O7).
+
+## 11. Verbesserungen der Optimierung: Automodus (Stand 05.10.2026)
+
+**Ausgangspunkt:** Jeder Suchplatz arbeitet wie im Original: besten Stand zurücksetzen, verändern (Neu würfeln, Termine füllen), bewerten und nur bei echter Verbesserung übernehmen. Vielfalt entsteht durch Inseln, Strategiemix, Neustarts und die Spezial-Insel. Gesucht war ein Verfahren, das einem Staffelleiter in 5, höchstens 10 Minuten einen besseren Plan liefert. Grundsatz: Erweiterungen ändern das Standardverhalten nicht; die Funktionsgleichheit mit dem Original (E7/E9) bleibt.
+
+**Versuche (02.–04.10.2026, verworfen und vor dem Zusammenführen wieder entfernt):** Gemessen wurde mit einem eigenen Vergleichsbefehl der Kommandozeile auf vier Referenzstaffeln (mehrere Läufe à 20–180 s, einzelne Langläufe à 10 min) gegen das Original. Die Streuung zwischen Läufen derselben Variante ist groß; Unterschiede unter etwa 10 % sind nicht belastbar.
+
+| Ansatz | Ergebnis |
+|---|---|
+| Annahmeregeln: gleich gute Stände (O1), Late Acceptance, Record-to-Record (O2) | keine Verbesserung, die wandernden Regeln deutlich schlechter |
+| Neue Eingriffe: teure Mannschaften, Zeiträume, Heimtermine tauschen (O3/O4) | nach 20 s teils 5–10 % besser, im Langlauf nicht |
+| Adaptive Strategiewahl und Kreuzung zweier Inseln (O5/O6) | im Rauschen |
+| Gelenktes Füllen (zuerst Termine ohne Sperr-/Ausweichtermin) | nichts Messbares |
+| Leitinseln nach Rangfolge, gezielte Reparatur teurer Begegnungen | etwas weniger A-Verstöße, aber langsamer und nicht billiger |
+| Iterierte lokale Suche und Simulated Annealing mit Zeitbudget | nach 3 min 20–30 % billiger, aber teils mehr A-Verstöße |
+| Gerüstbauer (Rundenturnier mit Zeitfenstern als Ausgangsplan) | leichte Verbesserung, kein Durchbruch |
+
+Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automatisch bessere Pläne – die Standardgewichtung stellt z. B. viele parallele Spiele nicht über einige C-Kosten. Nicht weiterverfolgt: OR-Tools CP-SAT (O7) und automatische Parametersuche (O8).
+
+**Automodus – Lenkung über die Gewichte** (Vorschlag Peter, Rest von E14; `Optimierungseinstellungen.Automodus`, `Automodus`): Der Automodus macht, was ein Staffelleiter von Hand tut – er ändert während der Rechnung Gewichte, bis die wichtigen Kriterien eingehalten sind. Ziel ist nicht der billigste Plan für eine feste Kostenfunktion, sondern möglichst wenige Verstöße in der Rangfolge, die der Staffelleiter festlegt.
+- **Einteilung:** Der Staffelleiter teilt die Kriterien (16 Mannschafts-Kostenarten, vereinsinterne Spiele am Anfang, Länge und Überlappung der Spieltage) in die Stufen A, B und C ein und ordnet sie (`Stufeneinteilung`, in der Qualitätsansicht; gespeichert als Attribut `kriterienstufen` am Knoten `plan`, das der alte AndiGenerator übernimmt, ohne es zu beachten). Kriterien ohne Anzahl gehören immer zu C. Standard: A = Hallenbelegung, parallele Spiele, Pflichtspieltage, vereinsinterne Spiele; B = Auswärts-/Heimkoppel, Sperr-, Ausweichtermine; C = die übrigen.
+- **Bewertung nach Stufen** (`Stufenwert`, immer mit den Gewichtungen des Anwenders): harte Fehler, A-, B-Verstöße, C-Ausreißer (Mannschaften mit mehr als zwei Verstößen mehr als in der Basis je C-Kriterium), C-Verstöße, C-Kosten.
+- **Basisoptimierung:** wie das Original, bis die Suche 10 s keine Verbesserung findet (mindestens 10 s); ihr bester Plan ist der Maßstab. Entfällt beim Wechsel von der Kostenoptimierung in den Automodus.
+- **Optimierung Stufe A, dann B:** die Gewichte der Mannschaften und Kriterien mit Verstößen der Stufe eine Stufe höher, bis nichts mehr anzuheben ist oder dreimal kein Fortschritt kommt; die A-Gewichte bleiben stehen.
+- **Optimierung Stufe C:** in der Reihenfolge der C-Kriterien zuerst die Ausreißer, sonst die Mannschaften mit den meisten Verstößen anheben; steigen A oder B (B um mehr als 1), wird zurückgezogen, beim besten Plan neu begonnen und das nächste Kriterium genommen. Sind alle C-Kriterien durch, wieder A und B.
+- **Ohne Zeitgrenzen:** Nächster Schritt, wenn sich die Verstöße der Suche 10 s nicht verbessert haben, frühestens 8 s nach der letzten Änderung (reine Rechenzeit ohne Pausen).
+- **Änderung der Einteilung während des Laufs:** keine neue Basisoptimierung; sofort weiter beim wichtigsten geänderten Kriterium – in A/B zuerst nur dieses Kriterium, dann die ganze Stufe; in C ab diesem Kriterium.
+- **Ergebnis:** der beste Plan nach Stufen, die angepassten Gewichtungen und die Liste der Änderungen (`Inseloptimierer.Lenkung`). Spezial-Insel im Automodus aus.
+- *Beispiel (Verbandsoberliga, 3 min):* A 18 → 3, B 13 → 4 bei etwa gleich vielen C-Verstößen; Peter: „arbeitet sehr gut“.
## Änderungsprotokoll
| Datum | Änderung |
@@ -685,3 +714,16 @@ Die **TT-Spielplan Engine** (Stephan Rüb, v1.1.2) ist ein neues, unabhängiges
| 01.10.2026 | Oberflächentests als eigenes Testprojekt `AndiGenerator.UI.Tests` mit Avalonia.Headless (MIT, nur für Tests): echte Anwendung mit Stilen und Skia-Zeichnung ohne Bildschirm; Rauchtest des Hauptfensters (Staffel öffnen, Einrichtung, alle Ansichten mit dem click-TT-Plan zeigen und zeichnen), der einfachen Dialoge und aller Seiten von „Spielplandaten bearbeiten“; Regressionstest Doppelklick auf einen Wunschtermin; Avalonia-Fehlermeldungen (Protokoll) lassen die Tests scheitern. `THIRD-PARTY-NOTICES.md` ohne Versionsnummern (maßgeblich sind `Directory.Packages.props` und die Workflows) |
| 01.10.2026 | Testabdeckung ohne Testprojekte: `.runsettings` im Lösungsordner misst nur die Programmschichten – Visual Studio erkennt die Datei selbst, `abdeckung.cmd` und CI geben sie mit `--settings` an |
| 01.10.2026 | Schnellerer Testlauf: die Geschwindigkeitsmessungen (`Messungen`, rund 20 s, laufen allein und halten alle anderen Tests auf) tragen die Kategorie `Messung` und laufen nicht mehr in `bauen.cmd`, `abdeckung.cmd`, CI und Release, sondern mit dem neuen `messen.cmd`; der Test der Spezial-Insel rechnet in Schritten von 10 000 Durchläufen nur noch so lange, bis die Spezial-Insel eine Insel neu gestartet hat (vorher fest 200 000, rund 14 s) |
+| 02.–04.10.2026 | Abschnitt 11: Versuche zur Optimierung (Annahmeregeln, neue Eingriffe, adaptive Strategiewahl, Kreuzung, gelenktes Füllen, Leitinseln/Reparatur, ILS/SA, Gerüstbauer) mit eigenem Vergleichsbefehl gemessen – verworfen und vor dem Zusammenführen entfernt; Ergebnisse zusammengefasst in Abschnitt 11 |
+| 04.10.2026 | Abschnitt 11: Automodus – Lenkung über die Gewichte je Mannschaft und Kriterium nach der Basisoptimierung, Bewertung nach Stufen A/B/C (`Automodus`, `Stufenwert`, `Stufeneinteilung`) |
+| 04.10.2026 | Einrichtungsseite: nur noch „Abschließen“; „Abschließen und generieren“ entfernt (Vorgabe Peter) – gestartet wird im Hauptfenster, wo Ausgangsplan und Verfahren gewählt werden |
+| 04.10.2026 | Verfahren am Startknopf statt eigener Auswahl (Vorgabe Peter: Kopfleiste überladen, Staffelname abgeschnitten): Pfeil rechts am Startknopf mit „Kostenoptimierung“ / „Automodus“ – vor dem Start starten, während der Generierung (auch pausiert) zum anderen Verfahren wechseln, ab dem besten Plan |
+| 04.10.2026 | Eine Kachel für den Stand der Optimierung (Vorgabe Peter): oben Verfahren (Kostenoptimierung bzw. Automodus mit Phase), in der Mitte Gesamtkosten bzw. Verstöße A, B, C (`Stufeneinteilung.Zaehlen` aus der Bewertung des besten Plans), unten die Veränderung seit dem ersten gültigen Plan; zusätzliche Automodus-Kachel und Verlauf-Tooltip entfernt |
+| 04.10.2026 | Automodus in der Oberfläche: Phasen heißen „Basisoptimierung“ (Grundlauf), „Optimierung Stufe A“, „Optimierung Stufe B“ und „Optimierung Stufe C“ (Glätten, ohne Kriterium); Drängen jetzt **nacheinander** (Entscheidung Peter): erst nur A-Gewichte, bis bei A nichts mehr geht (spätestens 35 % des Budgets), dann nur B-Gewichte bei stehenden A-Gewichten (spätestens 60 %), dann C; vorher wurden A und B gleichzeitig angehoben, und „Stufe B“ war kaum je zu sehen |
+| 04.10.2026 | Automodus ohne Zeitgrenzen (Vorgabe Peter: er reagiert nicht auf Zeitpunkte, sondern beobachtet, ob Gewichtsänderungen die Verstöße ändern): Grundlauf endet, wenn die Basisoptimierung 5 s keine Verbesserung findet (mindestens 10 s, keine Obergrenze); nächster Schritt erst, wenn sich die Verstöße im Plan der Suche (harte Fehler, A, B, C-Ausreißer, C-Anzahl) 5 s nicht mehr verbessert haben (mindestens 3 s nach der letzten Änderung); Höchstabstand und Budget-Grenzen der Stufen (35 %, 60 %) entfallen. Vergleichsläufe behalten ihre Dauer |
+| 04.10.2026 | Automodus: den Änderungen mehr Zeit geben (Vorgabe Peter) – Beobachtungsfenster 10 s statt 5 s ohne Verbesserung der Verstöße (bzw. der Kosten in der Basisoptimierung), frühestens 8 s statt 3 s nach der letzten Gewichtsänderung |
+| 04.10.2026 | Automodus in der Oberfläche (Vorgabe Peter): Ergebniskachel mit „Automodus · …“ in der Überschrift, Klick öffnet die Qualitätsansicht; Kachel „Pflichtregeln (Stufe A)“ im Automodus ausgeblendet (drei Kacheln) |
+| 04.10.2026 | Qualitätsansicht (Vorgabe Peter): Einteilung der Kriterien durch den Staffelleiter – Stufe A/B/C je Zeile wählen, Reihenfolge innerhalb der Stufe mit Pfeilen; Nummerierung A2 …, B1 …, C1 … nach der Reihenfolge, A1 (harte Fehler) fest, Spieltagslänge/-überlappung nur C. Gespeichert als Attribut `kriterienstufen` am Knoten `plan` der `.modifications` bzw. Plandatei – das Original übernimmt unbekannte Attribute beim Merge und schreibt sie beim Speichern wieder, beachtet sie aber nicht (unbekannte *Knoten* würden dort eine Ausnahme auslösen). Automodus, Kacheln und Qualitätsansicht verwenden diese Einteilung; keine festen Einstufungen mehr im Code (`Planqualitaet`), der Standard (E14 mit Koppeln in B) gilt nur, solange nichts gewählt ist. Kostenspalte nur bei der Kostenoptimierung; Ansicht schmaler |
+| 04.10.2026 | Qualitätsansicht: Einteilung nur im Automodus änderbar (Vorgabe Peter); A/B/C als Schaltergruppe und Pfeile als größere Knöpfe; Ziehen und Ablegen am Griff mit Vorschau (die gezogene Zeile bleibt als hervorgehobene Zeile sichtbar, die Tabelle zeigt Stufe und Nummer nach dem Ablegen; obere Zeilenhälfte = davor, untere = dahinter; Esc bricht ab; `Stufeneinteilung.Platzieren`) |
+| 04.10.2026 | Qualitätsansicht: Umstufen nach unten setzt an die erste Stelle der neuen Stufe, nach oben ans Ende (`Stufeneinteilung.Einstufen`); Pfeile am Stufenrand wechseln in die Nachbarstufe; beim Ziehen hängt die Zeile am Mauszeiger (Ebene über der Tabelle), die Tabelle zeigt an der Ablagestelle eine Lücke und nummeriert schon um |
+| 04.10.2026 | Spalte Einstufung breiter und links abgesetzt; Auswahlmenü des Startknopfs gestaltet (Titel, Erklärung, Haken). Start/Wechsel zum Automodus aktiviert die Qualitäts-, zur Kostenoptimierung die Kostenansicht. Kacheln: Pflichtregeln an Position 2; im Automodus nimmt die Ergebniskachel deren Platz ein (Grid statt UniformGrid, im engen Fenster 2 × 2). Ergebniskachel im Automodus: Verstöße seit der Basisoptimierung („A 14 → 3“) und Phase mit den Kriterien, an denen gearbeitet wird (`Lenkungsstand.Kriterien`: Drängen = zuletzt angehobene, Glätten = aktuelles C-Kriterium). Geänderte Einteilung während des Automodus ohne Neustart (`Inseloptimierer.EinteilungAendern`): Basis und bester Plan neu nach Stufen bewertet, weiter beim wichtigsten geänderten Kriterium (Drängen A/B bzw. Glätten ab dem C-Kriterium), sofern der Automodus nicht noch davor steht. Danach sofort (ohne Warten auf Stillstand) und in A/B zuerst nur das wichtigste geänderte Kriterium (Fokus), bis dort nichts mehr geht, dann die ganze Stufe. Kriterien in der Ergebniskachel unten als „In Arbeit: …“, alle mit Namen (oben war zu wenig Platz). Wechsel Kostenoptimierung → Automodus ohne Basisoptimierung (`Optimierungseinstellungen.AutoOhneGrundlauf`): bester Plan sofort Basis, erste Gewichtsänderung der Stufe A ohne Warten. „Start mit“: gewählter vorhandener Plan wird vor dem Start in allen (auch später geöffneten) Ansichten und den Kacheln gezeigt (`Generierungsanzeige.PlanZeigen`). Löschen gemerkter Pläne aus den Ansichten entfernt; stattdessen „Plan löschen“ oben an der Stelle von „Plan merken“, wenn vor dem Start ein gemerkter Plan gewählt ist. „Nach Excel“ aus den Ansichten in die linke Leiste (Ausgabe) mit Planmenü (`Exportziel`); Abschnitte der linken Leiste klappbar (in der schmalen Leiste immer offen). „Für click-TT exportieren“ ebenfalls mit Planmenü: auch gemerkte Pläne und der click-TT-Plan lassen sich als CSV exportieren. Excel-Export ausgebaut (Entscheidung Peter: zu ähnlich zum click-TT-Export): `Persistence.Tabellen` (Xlsx), `Tabellenexport`, Menü und Tests entfernt; „Für click-TT exportieren“ in der Leiste farblich hervorgehoben. Der Export nimmt ohne Menü den angezeigten Plan (aktive Ansicht, sonst Generierung bzw. Ausgangsplan) und ist ohne Plan deaktiviert und nicht hervorgehoben. Untere Zeile der Ergebniskachel im Automodus: statt „… seit dem ersten gültigen Plan“ (zweiter, widersprüchlicher Bezug) nur noch Zustandshinweise (angehalten, harte Fehler). Einrichtung: Punkt „Gespeicherte Kriterienreihenfolge“ (nur bei eigener Einteilung) mit Wahl „verwenden“ / „Standardreihenfolge nur für diese Sitzung“ (`Plansitzung.StandardKriterienstufenFuerDieseSitzung`, analog zu den gespeicherten Einstellungen). Einrichtung: Wahlkacheln fest unten bei den Knöpfen; „Abschließen“ übernimmt angeklickte, noch offene Entscheidungen (`Einrichtungsschritt.IstGewaehlt`). Qualitätsansicht: Klick auf eine Zeile zeigt rechts die Verstöße je Mannschaft (`Referenzbewertung.MeldungenJeMannschaft`, A1: harte Fehler); ohne Verstöße bleibt die Liste zu |
diff --git a/README.md b/README.md
index d86c191..4357576 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ AndiGenerator.NET ist die C#/.NET-Portierung des **AndiGenerators** von **Andrea
- Einrichtung nach dem Öffnen: alle Rückfragen (Spiellokale, Koppeltermine, Setzliste, Pflichtspieltage …) auf einer Seite
- Generierung auf allen Kernen, laufend übernommene Gewichtungen, Pläne merken und vergleichen
- Ansichten: Kostenmatrix, Qualität (Pflichtregeln), Meldungen, Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften
-- Ausgabe: CSV für click-TT, Excel-Arbeitsmappe, Ausdruck als PDF
+- Ausgabe: CSV für click-TT, Ausdruck als PDF
- Vor- und Rückrunde, Halbrunde, Doppelrunde, einzelne Runden
## Anleitung
@@ -48,7 +48,7 @@ Unter Windows erledigen `bauen.cmd` (bauen und testen) und `starten.cmd` dasselb
|---|---|
| `AndiGenerator.Domain` | Fachmodell: Staffel, Mannschaften, Termine, Optionen |
| `AndiGenerator.Engine` | Bewertung (Kostenfunktion) und Optimierung: Referenzmodell, schnelle Engine, Inselmodell |
-| `AndiGenerator.Persistence` | click-TT-Import, `.modifications`, Optionen, gemerkte Pläne, CSV und Excel |
+| `AndiGenerator.Persistence` | click-TT-Import, `.modifications`, Optionen, gemerkte Pläne, CSV |
| `AndiGenerator.Application` | Ablauf einer Sitzung, Optimierungsdienst, Datenbearbeitung |
| `AndiGenerator.Rendering` | Diagramme, Terminwunschraster und Ausdruck – für Bildschirm und PDF |
| `AndiGenerator.Presentation` | ViewModels (MVVM, ohne Oberflächenbibliothek) |
diff --git a/src/AndiGenerator.Application/Optimierungsdienst.cs b/src/AndiGenerator.Application/Optimierungsdienst.cs
index 657d89a..0f39c63 100644
--- a/src/AndiGenerator.Application/Optimierungsdienst.cs
+++ b/src/AndiGenerator.Application/Optimierungsdienst.cs
@@ -29,7 +29,10 @@ public sealed class Optimierungsdienst : IDisposable
private long fruehereDurchlaeufe;
private int fruehereVerbesserungen;
private double letzteKosten = -1;
+ private int letzteVerbesserungen = -1;
private bool pausiert;
+ private bool automodus;
+ private bool laufenderAutomodus;
/// Legt den Dienst an.
/// Einstellungen des Inselmodells; null = Standard.
@@ -77,6 +80,29 @@ public bool Pausiert
}
}
+ ///
+ /// Mit Automodus (MIGRATIONSPLAN Abschnitt 11): Die Suche lenkt sich selbst über die Gewichte, um Verstöße der
+ /// Stufen A und B herauszudrängen und danach C zu glätten. Gilt ab dem nächsten .
+ ///
+ public bool Automodus
+ {
+ get
+ {
+ lock (sperre)
+ {
+ return automodus;
+ }
+ }
+
+ set
+ {
+ lock (sperre)
+ {
+ automodus = value;
+ }
+ }
+ }
+
/// Fehler, an dem die Generierung abgebrochen ist; null, solange alles läuft.
public Exception? Fehler
{
@@ -135,7 +161,24 @@ public void DatenAendern(Staffel staffel, Berechnungsoptionen optionen)
fruehereDurchlaeufe += optimierer.Durchlaeufe;
fruehereVerbesserungen += bisher.Verbesserungen;
optimierer.Dispose();
- NeuStarten(staffel with { BestehenderSpielplan = bisher.Spiele }, optionen, ausgangsplanMelden: true);
+
+ // Wechsel von der Kostenoptimierung in den Automodus: Der Plan ist schon optimiert, keine neue Basisoptimierung.
+ bool wechselZumAutomodus = automodus && !laufenderAutomodus && bisher.Spiele.Count > 0;
+ NeuStarten(staffel with { BestehenderSpielplan = bisher.Spiele }, optionen, ausgangsplanMelden: true, ohneGrundlauf: wechselZumAutomodus);
+ }
+ }
+
+ ///
+ /// Übernimmt eine geänderte Einteilung der Kriterien in den laufenden Automodus: keine neue Basisoptimierung, er macht
+ /// beim wichtigsten geänderten Kriterium weiter. Bei der Kostenoptimierung ändert sich nichts.
+ ///
+ /// Die neue Einteilung.
+ public void EinteilungAendern(Stufeneinteilung neu)
+ {
+ ArgumentNullException.ThrowIfNull(neu);
+ lock (sperre)
+ {
+ optimierer?.EinteilungAendern(neu);
}
}
@@ -194,12 +237,20 @@ private Optimierungsstand StandIntern()
fruehereVerbesserungen + bester.Verbesserungen,
seitVerbesserung.Elapsed,
optimierer.SpezialKostenart,
- bester.Spiele);
+ bester.Spiele,
+ optimierer.Lenkung);
}
- private void NeuStarten(Staffel staffel, Berechnungsoptionen optionen, bool ausgangsplanMelden)
+ private void NeuStarten(Staffel staffel, Berechnungsoptionen optionen, bool ausgangsplanMelden, bool ohneGrundlauf = false)
{
- optimierer = new Inseloptimierer(staffel, optionen, einstellungen);
+ // Der Automodus lenkt nach der Einteilung der Kriterien, die der Staffelleiter in der Qualitätsansicht gewählt hat.
+ optimierer = new Inseloptimierer(staffel, optionen, einstellungen with
+ {
+ Automodus = automodus,
+ AutoStufen = Stufeneinteilung.AusText(staffel.Kriterienstufen),
+ AutoOhneGrundlauf = ohneGrundlauf,
+ });
+ laufenderAutomodus = automodus;
optimierer.Pausiert = pausiert;
optimierer.Starten();
@@ -207,6 +258,7 @@ private void NeuStarten(Staffel staffel, Berechnungsoptionen optionen, bool ausg
// sofort gemeldet: Er ist der beste Plan, und ohne Meldung blieben Ansichten und Pflichtregeln leer, bis er
// übertroffen wird. Ohne Ausgangsplan (noch keine Termine) wird erst der erste gefundene Plan gemeldet.
letzteKosten = ausgangsplanMelden ? -1 : optimierer.BesterStand().Kosten;
+ letzteVerbesserungen = ausgangsplanMelden ? -1 : optimierer.BesterStand().Verbesserungen;
seitVerbesserung.Restart();
}
@@ -222,10 +274,14 @@ private void Beobachten(CancellationToken token)
continue;
}
- double kosten = optimierer.BesterStand().Kosten;
- if (kosten >= 0 && (kosten < letzteKosten || letzteKosten < 0))
+ // Im Automodus zählt die Bewertung nach Verstößen; dort kann der beste Plan auch teurer werden.
+ Optimierungsergebnis bester = optimierer.BesterStand();
+ double kosten = bester.Kosten;
+ bool besser = kosten < letzteKosten || letzteKosten < 0 || (optimierer.Lenkung is not null && bester.Verbesserungen != letzteVerbesserungen);
+ if (kosten >= 0 && besser)
{
letzteKosten = kosten;
+ letzteVerbesserungen = bester.Verbesserungen;
seitVerbesserung.Restart();
neu = StandIntern();
}
diff --git a/src/AndiGenerator.Application/Optimierungsstand.cs b/src/AndiGenerator.Application/Optimierungsstand.cs
index fa1e497..33c8e94 100644
--- a/src/AndiGenerator.Application/Optimierungsstand.cs
+++ b/src/AndiGenerator.Application/Optimierungsstand.cs
@@ -4,6 +4,7 @@
using AndiGenerator.Domain.Optionen;
using AndiGenerator.Domain.Stammdaten;
+using AndiGenerator.Engine.Inseln;
namespace AndiGenerator.Application;
@@ -17,6 +18,7 @@ namespace AndiGenerator.Application;
/// Zeit seit der letzten Verbesserung.
/// Kostenart der Spezial-Insel, wenn sie läuft.
/// Der beste Plan (alle Spiele; = null für nicht terminierte).
+/// Stand des Automodus; null, wenn er nicht läuft.
public sealed record Optimierungsstand(
bool Laeuft,
bool Pausiert,
@@ -26,7 +28,8 @@ public sealed record Optimierungsstand(
int Verbesserungen,
TimeSpan SeitLetzterVerbesserung,
MannschaftsKostenart? SpezialKostenart,
- IReadOnlyList Spiele)
+ IReadOnlyList Spiele,
+ Lenkungsstand? Lenkung = null)
{
/// Stand ohne laufende Generierung.
public static Optimierungsstand Leer { get; } = new(false, false, 0, 0, -1, 0, TimeSpan.Zero, null, []);
diff --git a/src/AndiGenerator.Application/Planqualitaet.cs b/src/AndiGenerator.Application/Planqualitaet.cs
index 9c8e3b2..44c91b4 100644
--- a/src/AndiGenerator.Application/Planqualitaet.cs
+++ b/src/AndiGenerator.Application/Planqualitaet.cs
@@ -2,53 +2,81 @@
// SPDX-FileCopyrightText: 2026 Peter Buchmann
// SPDX-License-Identifier: GPL-3.0-only
+using System.Globalization;
using AndiGenerator.Domain.Optionen;
+using AndiGenerator.Engine.Inseln;
using AndiGenerator.Engine.Referenz;
namespace AndiGenerator.Application;
///
-/// Planqualität als Zahl der Verstöße je Kriterium in der Rangfolge aus MIGRATIONSPLAN E14 (A = Muss, B = Sperr- und
-/// Ausweichtermine, C = übrige). Ergänzt die gewichtete Kostensumme, die je nach Gewichtung schwer zu deuten ist.
+/// Planqualität als Zahl der Verstöße je Kriterium in der Rangfolge aus MIGRATIONSPLAN E14: zuerst die harten Fehler (A1),
+/// dann die Kriterien in der Einteilung des Staffelleiters (Stufen A, B, C, darin in seiner Reihenfolge nummeriert).
+/// Ergänzt die gewichtete Kostensumme, die je nach Gewichtung schwer zu deuten ist.
///
public static class Planqualitaet
{
/// Kosten je hartem Fehler im Original (10 × cMaxKostenNoHardError).
private const double KostenJeHartemFehler = 10.0 * 1_000_000_000.0;
- private static readonly MannschaftsKostenart[] StufeA = [MannschaftsKostenart.Hallenbelegung, MannschaftsKostenart.ParalleleSpiele];
-
- private static readonly MannschaftsKostenart[] StufeB = [MannschaftsKostenart.Sperrtermine, MannschaftsKostenart.Ausweichtermine];
-
/// Ermittelt die Kriterien aus einer Bewertung.
/// Bewertung des Plans.
- /// Die Kriterien in der Rangfolge A1 … C.
- public static IReadOnlyList Kriterien(Planbewertung bewertung)
+ /// Einteilung der Kriterien; null = .
+ /// Die Kriterien: A1 (harte Fehler), dann A2 …, B1 …, C1 … in der Reihenfolge der Einteilung.
+ public static IReadOnlyList Kriterien(Planbewertung bewertung, Stufeneinteilung? einteilung = null)
{
ArgumentNullException.ThrowIfNull(bewertung);
+ Stufeneinteilung e = (einteilung ?? Stufeneinteilung.Standard).Vervollstaendigt();
var ergebnis = new List
{
HarterFehler("Spiele ohne Termin", bewertung.NichtTerminiert),
HarterFehler("Spiele mit ungültigem Termin", bewertung.UngueltigeSpiele),
HarterFehler("Spiele an spielfreien Tagen", bewertung.SpieleAnSpielfreienTagen),
- Kostenart(bewertung, "A2", MannschaftsKostenart.Hallenbelegung),
- Kostenart(bewertung, "A3", MannschaftsKostenart.ParalleleSpiele),
- new("A4", "Vereinsinterne Spiele am Anfang", null, null, bewertung.VereinsinterneSpieleAmAnfang),
- Kostenart(bewertung, "A5", MannschaftsKostenart.Pflichtspieltage),
- Kostenart(bewertung, "B1", MannschaftsKostenart.Sperrtermine),
- Kostenart(bewertung, "B2", MannschaftsKostenart.Ausweichtermine),
};
-
- ergebnis.AddRange(bewertung.SichtbareKostenarten
- .Where(a => !StufeA.Contains(a) && !StufeB.Contains(a) && a != MannschaftsKostenart.Pflichtspieltage)
- .Select(a => Kostenart(bewertung, "C", a)));
- ergebnis.Add(new("C", "Überlappung Spieltage", null, null, bewertung.UeberlappungSpieltage));
- ergebnis.Add(new("C", "Länge Spieltage", null, null, bewertung.LaengeSpieltage));
- ergebnis.Add(new("C", "Überlappung letzter Spieltag", null, null, bewertung.UeberlappungLetzterSpieltag));
- ergebnis.Add(new("C", "Länge letzter Spieltag", null, null, bewertung.LaengeLetzterSpieltag));
+ ergebnis.AddRange(Stufe(bewertung, "A", e.A, 2));
+ ergebnis.AddRange(Stufe(bewertung, "B", e.B, 1));
+ ergebnis.AddRange(Stufe(bewertung, "C", e.C, 1));
return ergebnis;
}
+ /// Anzeigename eines Kriteriums, wie in der Qualitätsansicht.
+ /// Das Kriterium.
+ /// Der Name, z. B. „Sperrtermine“ oder „Länge Spieltage“.
+ public static string Name(Kostenkriterium k) => k switch
+ {
+ Kostenkriterium.VereinsinterneSpieleAmAnfang => "Vereinsinterne Spiele am Anfang",
+ Kostenkriterium.Spieltaglaenge => "Länge Spieltage",
+ Kostenkriterium.Spieltagueberlappung => "Überlappung Spieltage",
+ Kostenkriterium.LetzterSpieltagLaenge => "Länge letzter Spieltag",
+ Kostenkriterium.LetzterSpieltagUeberlappung => "Überlappung letzter Spieltag",
+ _ => Gewichtungsanzeige.Name((MannschaftsKostenart)(int)k),
+ };
+
+ private static IEnumerable Stufe(Planbewertung bewertung, string stufe, IReadOnlyList kriterien, int erste)
+ {
+ int nummer = erste;
+ foreach (Kostenkriterium k in kriterien)
+ {
+ // Kostenarten, die in dieser Staffel keine Rolle spielen (z. B. Setzliste ohne Setzliste), zeigt die Ansicht in C nicht.
+ if (stufe == "C" && (int)k < (int)Kostenkriterium.VereinsinterneSpieleAmAnfang && !bewertung.SichtbareKostenarten.Contains((MannschaftsKostenart)(int)k))
+ {
+ continue;
+ }
+
+ yield return Kriterium(bewertung, string.Create(CultureInfo.InvariantCulture, $"{stufe}{nummer++}"), k);
+ }
+ }
+
+ private static Qualitaetskriterium Kriterium(Planbewertung bewertung, string stufe, Kostenkriterium k) => k switch
+ {
+ Kostenkriterium.VereinsinterneSpieleAmAnfang => new(stufe, Name(k), null, null, bewertung.VereinsinterneSpieleAmAnfang, k),
+ Kostenkriterium.Spieltaglaenge => new(stufe, Name(k), null, null, bewertung.LaengeSpieltage, k),
+ Kostenkriterium.Spieltagueberlappung => new(stufe, Name(k), null, null, bewertung.UeberlappungSpieltage, k),
+ Kostenkriterium.LetzterSpieltagLaenge => new(stufe, Name(k), null, null, bewertung.LaengeLetzterSpieltag, k),
+ Kostenkriterium.LetzterSpieltagUeberlappung => new(stufe, Name(k), null, null, bewertung.UeberlappungLetzterSpieltag, k),
+ _ => Kostenart(bewertung, stufe, (MannschaftsKostenart)(int)k) with { Kriterium = k },
+ };
+
private static Qualitaetskriterium HarterFehler(string name, double kosten) =>
new("A1", name, (int)Math.Round(kosten / KostenJeHartemFehler), null, kosten);
diff --git a/src/AndiGenerator.Application/Plansitzung.cs b/src/AndiGenerator.Application/Plansitzung.cs
index 9c1d0ba..5a95228 100644
--- a/src/AndiGenerator.Application/Plansitzung.cs
+++ b/src/AndiGenerator.Application/Plansitzung.cs
@@ -31,6 +31,7 @@ public sealed class Plansitzung
private const string Uebernahmevermerk = "uebernommen-aus-AndiGeneratorNeu.txt";
private readonly DatenKnoten stand;
+ private bool standardKriterienstufen;
private Plansitzung(string pfad, DatenKnoten stand, bool istClickTt, bool ersterStart, string basisordner, string? uebernahmeAus)
{
@@ -210,7 +211,33 @@ public void DatenUebernehmen(Datenbearbeitung bearbeitung)
}
stand.Zuweisen(bearbeitung.Arbeitsstand);
- Staffel = neu;
+ Staffel = standardKriterienstufen ? neu with { Kriterienstufen = string.Empty } : neu;
+ }
+
+ ///
+ /// Speichert die vom Staffelleiter gewählte Einteilung der Kriterien (Stufen A, B, C mit Reihenfolge) mit den
+ /// Spielplandaten: bei click-TT-Dateien in der .modifications, sonst in der Plandatei (Attribut am Knoten
+ /// plan, das der alte AndiGenerator übernimmt, ohne es zu beachten).
+ ///
+ /// Die Einteilung als Text (Stufeneinteilung.Text).
+ /// Die Datei konnte nicht geschrieben werden.
+ public void KriterienstufenSpeichern(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ standardKriterienstufen = false;
+ Datenbearbeitung bearbeitung = DatenBearbeiten();
+ bearbeitung.Arbeitsstand.Setzen(StaffelAbbildung.KriterienAttribut, text);
+ DatenUebernehmen(bearbeitung);
+ }
+
+ ///
+ /// Rechnet in dieser Sitzung mit der Standard-Einteilung der Kriterien, ohne die gespeicherte zu überschreiben (wie
+ /// ). Ändert der Staffelleiter die Einteilung, gilt und speichert er seine.
+ ///
+ public void StandardKriterienstufenFuerDieseSitzung()
+ {
+ standardKriterienstufen = true;
+ Staffel = Staffel with { Kriterienstufen = string.Empty };
}
/// Übernimmt neue Optionen und speichert sie im Staffelordner.
@@ -349,6 +376,13 @@ public void RundeUebernehmen(Rundenvorschlag vorschlag)
public IReadOnlyList Meldungen(IReadOnlyList spiele, string mannschaftsName, MannschaftsKostenart art) =>
Referenzbewertung.Meldungen(Staffel with { BestehenderSpielplan = spiele }, Optionen, mannschaftsName, art);
+ /// Meldungen aller Mannschaften zu einer Kostenart (Einzelheiten eines Kriteriums in der Qualitätsansicht).
+ /// Spiele des Plans.
+ /// Kostenart.
+ /// Je Mannschaft ihre Meldungen.
+ public IReadOnlyList MeldungenJeMannschaft(IReadOnlyList spiele, MannschaftsKostenart art) =>
+ Referenzbewertung.MeldungenJeMannschaft(Staffel with { BestehenderSpielplan = spiele }, Optionen, art);
+
/// Ändert eine Gewichtung und speichert die Optionen im Staffelordner (wie das Original nach dem Gewichtungsdialog).
/// Die Gewichtung.
/// Der neue Wert.
diff --git a/src/AndiGenerator.Application/Qualitaetskriterium.cs b/src/AndiGenerator.Application/Qualitaetskriterium.cs
index 540d96d..817547c 100644
--- a/src/AndiGenerator.Application/Qualitaetskriterium.cs
+++ b/src/AndiGenerator.Application/Qualitaetskriterium.cs
@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2026 Peter Buchmann
// SPDX-License-Identifier: GPL-3.0-only
+using AndiGenerator.Engine.Inseln;
+
namespace AndiGenerator.Application;
/// Ein Kriterium der Planqualität nach der Rangfolge aus MIGRATIONSPLAN E14.
@@ -10,7 +12,8 @@ namespace AndiGenerator.Application;
/// Zahl der Verstöße; null, wenn die Kostenart keine Anzahl kennt (dann zählen nur die Kosten).
/// Zahl der Wünsche, gegen die geprüft wurde (z. B. gemeldete Sperrtermine), sonst null.
/// Kosten dieses Kriteriums.
-public sealed record Qualitaetskriterium(string Stufe, string Name, int? Anzahl, int? Gesamtzahl, double Kosten)
+/// Das einstufbare Kriterium; null bei den harten Fehlern (immer A1).
+public sealed record Qualitaetskriterium(string Stufe, string Name, int? Anzahl, int? Gesamtzahl, double Kosten, Kostenkriterium? Kriterium = null)
{
/// Holt einen Wert, der angibt, ob das Kriterium ohne Verstoß erfüllt ist.
public bool Erfuellt => Anzahl is int anzahl ? anzahl == 0 : Kosten <= 0;
diff --git a/src/AndiGenerator.Domain/Stammdaten/Staffel.cs b/src/AndiGenerator.Domain/Stammdaten/Staffel.cs
index 59f1ba0..255d291 100644
--- a/src/AndiGenerator.Domain/Stammdaten/Staffel.cs
+++ b/src/AndiGenerator.Domain/Stammdaten/Staffel.cs
@@ -35,6 +35,13 @@ public sealed record Staffel(
Setzliste? Setzliste,
IReadOnlyList BestehenderSpielplan)
{
+ ///
+ /// Holt die vom Staffelleiter gewählte Einteilung der Kriterien in die Stufen A, B und C mit ihrer Reihenfolge
+ /// (Attribut kriterienstufen am Knoten plan, z. B. A:Hallenbelegung,ParalleleSpiele;B:…;C:…;
+ /// nicht im Original, das unbekannte Attribute beim Laden übernimmt und beim Speichern wieder schreibt). Leer = Standard.
+ ///
+ public string Kriterienstufen { get; init; } = string.Empty;
+
/// Holt eine leere Staffel (Original: „Neu“ im Menü).
public static Staffel Leer { get; } = new(string.Empty, string.Empty, string.Empty, null, null, null, [], [], [], [], null, []);
}
diff --git a/src/AndiGenerator.Engine/Inseln/Automodus.cs b/src/AndiGenerator.Engine/Inseln/Automodus.cs
new file mode 100644
index 0000000..e02d226
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Automodus.cs
@@ -0,0 +1,742 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+using AndiGenerator.Domain.Optionen;
+using AndiGenerator.Engine.Kern;
+using AndiGenerator.Engine.Referenz;
+
+namespace AndiGenerator.Engine.Inseln;
+
+///
+/// Automodus (MIGRATIONSPLAN Abschnitt 11, Vorschlag Peter): lenkt die Suche über die Gewichtungen, wie es ein
+/// erfahrener Anwender an der Oberfläche tut. Die Einteilung der Kriterien in A, B und C und die Wichtigkeit der
+/// C-Kriterien legt der Staffelleiter fest ().
+///
+/// Grundlauf mit den Gewichtungen des Anwenders; sein bester Plan ist der Maßstab je Mannschaft und C-Kriterium.
+///
+/// Drängen, erst Stufe A, dann Stufe B: Der Automodus beobachtet die Verstöße im Plan der Suche. Ändern sie sich nach einer
+/// Gewichtsänderung 10 s lang nicht mehr, steigen die Gewichte genau der Mannschaften und Kriterien um eine Stufe, die einen
+/// Verstoß der Stufe haben – bis bei ihr nichts mehr geht (nichts mehr anzuheben oder dreimal kein Fortschritt). Die
+/// A-Gewichte bleiben beim Drängen von B stehen. Es gibt keine Zeitgrenzen.
+///
+///
+/// Glätten, ab dem besten Plan: C-Kriterien nach Wichtigkeit, Verstöße allgemein reduzieren, Spitzen zuerst. Eine
+/// Mannschaft reißt bei einem C-Kriterium aus, wenn sie mehr als zwei Verstöße mehr hat als im Grundlauf – ein, zwei sind
+/// in Ordnung, zu viele bei einer Mannschaft sind unfair. Für die Ausreißer, sonst für die Mannschaften mit den meisten
+/// Verstößen steigt das Gewicht des Kriteriums eine Stufe, so lange, bis nichts mehr anzuheben ist (dann das nächste
+/// Kriterium) oder A oder B wieder steigen: Dann wird die Anhebung zurückgezogen, beim besten Plan neu begonnen und mit dem
+/// nächsten Kriterium weitergemacht. Sind alle C-Kriterien durch, wird wieder gedrängt.
+///
+///
+/// Der beste Plan wird immer mit den Gewichtungen des Anwenders bewertet: harte Fehler, A, B, C-Ausreißer, C-Verstöße, dann
+/// C-Kosten ().
+///
+///
+///
+internal sealed class Automodus
+{
+ /// Sekunden, denen ein Lauf mit fester Zahl von Durchläufen () ohne Zeitbudget entspricht.
+ public const double StandardBudget = 300;
+
+ /// Mindestdauer des Grundlaufs in Sekunden.
+ private const double MinGrundlauf = 10;
+
+ /// Sekunden ohne Verbesserung der Suche, ab denen sie als festgefahren gilt.
+ private const double Stillstand = 10;
+
+ /// Mindestabstand zweier Änderungen in Sekunden.
+ private const double MinPhase = 8;
+
+ /// Änderungen ohne weniger A/B-Verstöße im Plan der Suche, nach denen das Drängen endet.
+ private const int DraengenOhneFortschritt = 3;
+
+ /// Mindestspielraum bei den Kosten für Kriterien ohne Anzahl (Spieltage), als Anteil der gesamten C-Kosten des Maßstabs.
+ private const double SpielraumKosten = 0.02;
+
+ private readonly KernPlan bewerter;
+ private readonly string[] ids;
+ private readonly string[] namen;
+ private readonly Loesung basisLoesung;
+ private readonly int spielraum;
+ private readonly double kostenToleranz;
+ private readonly Berechnungsoptionen original;
+ private readonly double[] anzahl = new double[Stufeneinteilung.AnzahlKriterien];
+ private readonly double[] kosten = new double[Stufeneinteilung.AnzahlKriterien];
+ private readonly double[] grenzeKosten = new double[Stufeneinteilung.AnzahlKriterien];
+ private readonly int[] basisMannschaft;
+ private readonly List angehobenC = [];
+ private readonly List aenderungen = [];
+ private Phase phase = Phase.Grundlauf;
+ private (int, int, int)? draengBester;
+ private int ohneDraengfortschritt;
+ private int indexC;
+ private Stufe draengStufe = Stufe.A;
+ private (int A, int B) bezug;
+ private double letzteAenderung;
+ private double letzteSuche;
+ private double sucheKosten = -1;
+ private (int, int, int, int, int)? sucheVerstoesse;
+ private double sekunden;
+ private int zurueckgezogen;
+ private int zaehlerAnhebungen;
+ private Stufeneinteilung einteilung;
+ private Stufe[] stufen;
+ private int[] reihenfolgeC;
+ private Kostenkriterium[] draengKriterien = [];
+ private Kostenkriterium? fokus;
+ private bool sofort;
+
+ /// Legt den Automodus an.
+ /// Referenzmodell mit den Gewichtungen des Anwenders.
+ /// Stammdaten dazu.
+ /// Einstellungen (Spielraum, Stufen).
+ public Automodus(RefPlan referenz, KernDefinition definition, Optimierungseinstellungen einstellungen)
+ {
+ bewerter = new KernPlan(referenz, definition);
+ bewerter.UngueltigeTermineEntfernen();
+ bewerter.Merken();
+ ids = referenz.Mannschaften.Select(m => m.TeamId).ToArray();
+ namen = definition.Name;
+ einteilung = (einstellungen.AutoStufen ?? Stufeneinteilung.Standard).Vervollstaendigt();
+ stufen = einteilung.AlsFeld();
+ reihenfolgeC = einteilung.C.Select(k => (int)k).ToArray();
+ spielraum = einstellungen.AutoSpielraum;
+ kostenToleranz = einstellungen.AutoKostenToleranz;
+ original = referenz.Optionen;
+ Optionen = referenz.Optionen;
+ basisMannschaft = new int[ids.Length * KernDefinition.AnzahlArten];
+ Beste = new Loesung(bewerter.AnzahlErlaubt);
+ basisLoesung = new Loesung(bewerter.AnzahlErlaubt);
+ }
+
+ private enum Phase
+ {
+ Grundlauf,
+ Draengen,
+ Glaetten,
+ }
+
+ /// Gets die aktuell geltenden Gewichtungen.
+ public Berechnungsoptionen Optionen { get; private set; }
+
+ /// Gets a value indicating whether noch der Grundlauf läuft (keine Lenkung).
+ public bool Grundlauf => phase == Phase.Grundlauf;
+
+ /// Gets den besten Plan nach Stufen (gültig nach dem Grundlauf).
+ public Loesung Beste { get; }
+
+ /// Gets seine Kosten mit den Gewichtungen des Anwenders.
+ public double BesteKosten { get; private set; }
+
+ /// Gets seine Bewertung nach Stufen.
+ public Stufenwert BesterWert { get; private set; }
+
+ /// Gets die Anzahl der Verbesserungen des besten Plans nach Stufen seit dem Grundlauf.
+ public int Verbesserungen { get; private set; }
+
+ /// Gets die Bewertung des Plans der Suche beim letzten .
+ public Stufenwert? Suche { get; private set; }
+
+ /// Gets die Bewertung am Ende des Grundlaufs.
+ public Stufenwert? Basis { get; private set; }
+
+ /// Gets or sets a value indicating whether die Suche nach der nächsten Änderung beim besten Plan neu beginnen soll (sonst beim aktuellen Plan der Suche).
+ public bool NeustartVomBesten { get; set; }
+
+ /// Gets den aktuellen Stand für Anzeige und Auswertung.
+ public Lenkungsstand Stand => new(
+ Basis,
+ BesterWert,
+ Suche,
+ PhaseText,
+ Bezeichnung,
+ zaehlerAnhebungen,
+ zurueckgezogen,
+ Optionen,
+ aenderungen.ToArray(),
+ AktuelleKriterien);
+
+ /// Rang der laufenden Phase wie bei : Drängen A = 0, Drängen B = 1, Glätten des i-ten C-Kriteriums = 2 + i.
+ private int Rang => phase switch
+ {
+ Phase.Draengen => draengStufe == Stufe.A ? 0 : 1,
+ Phase.Glaetten => 2 + indexC,
+ _ => -1,
+ };
+
+ /// Die Kriterien, an denen gerade gearbeitet wird: beim Drängen die zuletzt angehobenen, beim Glätten das aktuelle C-Kriterium.
+ private Kostenkriterium[] AktuelleKriterien => phase switch
+ {
+ Phase.Draengen => draengKriterien,
+ Phase.Glaetten when indexC < reihenfolgeC.Length => [(Kostenkriterium)reihenfolgeC[indexC]],
+ _ => [],
+ };
+
+ /// Bezeichnung der Phase für die Oberfläche: Basisoptimierung, dann Optimierung der Stufe A, B oder C.
+ private string Bezeichnung => phase switch
+ {
+ Phase.Grundlauf => "Basisoptimierung",
+ Phase.Draengen => draengStufe == Stufe.A ? "Optimierung Stufe A" : "Optimierung Stufe B",
+ _ => "Optimierung Stufe C",
+ };
+
+ private string PhaseText => phase switch
+ {
+ Phase.Grundlauf => "Grundlauf",
+ Phase.Draengen => "Drängen",
+ _ => indexC < reihenfolgeC.Length ? $"Glätten {(Kostenkriterium)reihenfolgeC[indexC]}" : "Glätten",
+ };
+
+ /// Merkt sich im Grundlauf, wann die Suche (Kostenoptimierung) zuletzt besser geworden ist.
+ /// Beste Kosten der Suche mit den aktuellen Gewichten.
+ /// Sekunden reiner Rechenzeit.
+ public void Beobachten(double besteKosten, double jetzt)
+ {
+ if (Grundlauf && besteKosten >= 0 && (sucheKosten < 0 || besteKosten < sucheKosten))
+ {
+ sucheKosten = besteKosten;
+ letzteSuche = jetzt;
+ }
+ }
+
+ /// Der Grundlauf ist vorbei, wenn die Basisoptimierung steht (nach mindestens 10 s, 10 s ohne Verbesserung).
+ /// Sekunden reiner Rechenzeit.
+ /// true, wenn die Lenkung beginnen kann.
+ public bool GrundlaufVorbei(double jetzt) => jetzt >= MinGrundlauf && jetzt - letzteSuche >= Stillstand;
+
+ /// Beendet den Grundlauf: der beste Plan wird Maßstab je Mannschaft und C-Kriterium.
+ /// Bester Plan des Grundlaufs.
+ /// Sekunden reiner Rechenzeit.
+ /// true, um gleich die erste Änderung der Gewichte zu machen (ohne auf Stillstand zu warten).
+ public void GrundlaufBeenden(Loesung beste, double jetzt, bool sofortLenken = false)
+ {
+ Stufenwert wert = Bewerten(beste, out double gesamt);
+ for (int s = 0; s < basisMannschaft.Length; s++)
+ {
+ basisMannschaft[s] = Math.Max(0, bewerter.KostenAnzahl[s]);
+ }
+
+ double spielraumKosten = SpielraumKosten * Math.Max(1, wert.C);
+ for (int k = 0; k < stufen.Length; k++)
+ {
+ grenzeKosten[k] = Math.Max(kosten[k] * (1 + kostenToleranz), kosten[k] + spielraumKosten);
+ }
+
+ phase = Phase.Draengen;
+ draengStufe = Stufe.A;
+ Basis = wert;
+ beste.KopierenNach(Beste);
+ beste.KopierenNach(basisLoesung);
+ BesterWert = wert;
+ BesteKosten = gesamt;
+ letzteAenderung = jetzt;
+ letzteSuche = jetzt;
+ sofort = sofortLenken;
+ sucheVerstoesse = null;
+ }
+
+ ///
+ /// Der Staffelleiter hat die Einteilung geändert (während der Generierung): Keine neue Basisoptimierung, die Gewichte
+ /// bleiben. Basis und bester Plan werden nach der neuen Einteilung bewertet, und der Automodus macht beim wichtigsten
+ /// geänderten Kriterium weiter – in Stufe A oder B mit dem Drängen dieser Stufe, in C mit dem Glätten ab diesem Kriterium –,
+ /// sofern er nicht ohnehin noch davor steht.
+ ///
+ /// Die neue Einteilung.
+ /// Sekunden reiner Rechenzeit.
+ public void EinteilungAendern(Stufeneinteilung neu, double jetzt)
+ {
+ ArgumentNullException.ThrowIfNull(neu);
+ Stufeneinteilung alt = einteilung;
+ neu = neu.Vervollstaendigt();
+ if (neu.Text() == alt.Text())
+ {
+ return;
+ }
+
+ einteilung = neu;
+ stufen = neu.AlsFeld();
+ reihenfolgeC = neu.C.Select(k => (int)k).ToArray();
+ if (Grundlauf)
+ {
+ return;
+ }
+
+ Basis = Bewerten(basisLoesung, out _);
+ BesterWert = Bewerten(Beste, out double gesamt);
+ BesteKosten = gesamt;
+ sucheVerstoesse = null;
+
+ (int ziel, Kostenkriterium? geaendert) = ErsteAenderung(alt, neu);
+ if (ziel > Rang)
+ {
+ aenderungen.Add($"{jetzt:0} s Einteilung geändert: weiter bei {PhaseText}");
+ return;
+ }
+
+ if (ziel <= 1)
+ {
+ phase = Phase.Draengen;
+ draengStufe = ziel == 0 ? Stufe.A : Stufe.B;
+ draengBester = null;
+ ohneDraengfortschritt = 0;
+ draengKriterien = geaendert is Kostenkriterium k ? [k] : [];
+ fokus = geaendert;
+ }
+ else
+ {
+ phase = Phase.Glaetten;
+ indexC = ziel - 2;
+ angehobenC.Clear();
+ bezug = (BesterWert.A, BesterWert.B);
+ fokus = null;
+ }
+
+ // Sofort lenken: nicht erst warten, bis die Suche wieder steht.
+ sofort = true;
+ aenderungen.Add($"{jetzt:0} s Einteilung geändert, bester Plan {BesterWert}: weiter bei {PhaseText}{(fokus is { } f ? " ab " + f : string.Empty)}");
+ }
+
+ /// Bewertet den Plan der Suche (bester nach den aktuellen Gewichten) für die Anzeige.
+ /// Der Plan.
+ /// Sekunden reiner Rechenzeit.
+ public void Verfolgen(Loesung suche, double jetzt)
+ {
+ Stufenwert wert = Bewerten(suche, out _);
+ Suche = wert;
+
+ // Beobachtet wird, ob sich die Verstöße noch ändern: Jede Verbesserung der Anzahlen setzt die Uhr des Stillstands zurück.
+ var verstoesse = (wert.Harte, wert.A, wert.B, wert.Ausreisser, wert.CAnzahl);
+ if (!Grundlauf && (sucheVerstoesse is not { } bisher || verstoesse.CompareTo(bisher) < 0))
+ {
+ sucheVerstoesse = verstoesse;
+ letzteSuche = jetzt;
+ }
+ }
+
+ /// Übernimmt als besten Plan, wenn sie nach Stufen besser ist.
+ /// Lösung einer Insel.
+ public void Pruefen(Loesung loesung)
+ {
+ Stufenwert wert = Bewerten(loesung, out double gesamt);
+ if (wert.IstBesserAls(BesterWert))
+ {
+ loesung.KopierenNach(Beste);
+ BesterWert = wert;
+ BesteKosten = gesamt;
+ Verbesserungen++;
+ }
+ }
+
+ /// Entscheidet über die nächste Änderung der Gewichte, wenn die Suche steht (oder nach der Höchstdauer).
+ /// Bester Plan nach den aktuellen Gewichten.
+ /// Sekunden reiner Rechenzeit.
+ /// Die neuen Gewichtungen; null = keine Änderung.
+ public Berechnungsoptionen? Lenken(Loesung suche, double jetzt)
+ {
+ if (Grundlauf || (!sofort && (jetzt - letzteAenderung < MinPhase || jetzt - letzteSuche < Stillstand)))
+ {
+ return null;
+ }
+
+ sofort = false;
+
+ letzteAenderung = jetzt;
+ sekunden = jetzt;
+
+ // Was sich ändert, bestimmt der Plan der Suche (wie ihn ein Anwender in der Oberfläche sieht).
+ Stufenwert wert = Bewerten(suche, out _);
+ Berechnungsoptionen neu = phase == Phase.Draengen ? DraengenSchritt(wert) : GlaettenSchritt(wert, Optionen);
+ if (neu == Optionen && !NeustartVomBesten)
+ {
+ return null;
+ }
+
+ Optionen = neu;
+ sucheVerstoesse = null;
+ letzteSuche = jetzt;
+ return neu;
+ }
+
+ /// Eine Stufe höher, mindestens „normal“, höchstens „extrem hoch“.
+ private static Gewichtung Hoeher(Gewichtung g) => g < Gewichtung.Normal ? Gewichtung.Normal : (Gewichtung)Math.Min((int)g + 1, (int)Gewichtung.ExtremHoch);
+
+ /// Eine Stufe niedriger, nicht unter die Gewichtung des Anwenders.
+ private static Gewichtung Niedriger(Gewichtung g, Gewichtung untergrenze) => (Gewichtung)Math.Max((int)g - 1, (int)untergrenze);
+
+ /// Schlüssel eines Gewichts für das ganze Kriterium (alle Mannschaften bzw. Plan-Gewichtung).
+ private static int Gesamtschluessel(int kriterium) => -kriterium - 1;
+
+ /// Setzt das Gewicht einer Kostenart für alle Mannschaften.
+ private static Berechnungsoptionen GesamtSetzen(Berechnungsoptionen optionen, int art, Gewichtung wert)
+ {
+ Gewichtung[] je = optionen.JeKostenart.ToArray();
+ je[art] = wert;
+ return optionen with { JeKostenart = je };
+ }
+
+ private static bool Anhebbar(Gewichtung g) => g is not Gewichtung.NichtBeruecksichtigen and < Gewichtung.ExtremHoch;
+
+ ///
+ /// Rang der wichtigsten Änderung zwischen zwei Einteilungen: 0 = in Stufe A, 1 = in Stufe B, 2 + i = beim i-ten
+ /// C-Kriterium (Stelle oder Stufe eines Kriteriums anders).
+ ///
+ private static (int Rang, Kostenkriterium? Kriterium) ErsteAenderung(Stufeneinteilung alt, Stufeneinteilung neu)
+ {
+ Kostenkriterium[] vorher = [.. alt.A, .. alt.B, .. alt.C];
+ Kostenkriterium[] nachher = [.. neu.A, .. neu.B, .. neu.C];
+ int i = 0;
+ while (i < nachher.Length && i < vorher.Length && vorher[i] == nachher[i] && alt.StufeVon(vorher[i]) == neu.StufeVon(nachher[i]))
+ {
+ i++;
+ }
+
+ Kostenkriterium? kriterium = i < nachher.Length ? nachher[i] : null;
+ if (i < neu.A.Count)
+ {
+ return (0, kriterium);
+ }
+
+ return (i < neu.A.Count + neu.B.Count ? 1 : 2 + i - neu.A.Count - neu.B.Count, kriterium);
+ }
+
+ ///
+ /// Drängen, erst Stufe A, dann Stufe B (die A-Gewichte bleiben stehen): Verstöße der Stufe im Plan der Suche herausdrängen,
+ /// bis bei ihr nichts mehr geht (nichts mehr anzuheben oder dreimal kein Fortschritt); dann beginnt
+ /// das Glätten beim besten Plan.
+ ///
+ private Berechnungsoptionen DraengenSchritt(Stufenwert wert)
+ {
+ // Nach einer geänderten Einteilung zuerst nur das wichtigste geänderte Kriterium, dann die ganze Stufe.
+ if (fokus is Kostenkriterium f)
+ {
+ (int, int, int) fokusStand = (wert.Harte, (int)anzahl[(int)f], 0);
+ if (draengBester is not { } vorher || fokusStand.CompareTo(vorher) < 0)
+ {
+ draengBester = fokusStand;
+ ohneDraengfortschritt = 0;
+ }
+ else
+ {
+ ohneDraengfortschritt++;
+ }
+
+ Berechnungsoptionen nurFokus = Draengen(draengStufe);
+ if (nurFokus != Optionen && ohneDraengfortschritt < DraengenOhneFortschritt)
+ {
+ return nurFokus;
+ }
+
+ aenderungen.Add($"{sekunden:0} s bei {f} geht nichts mehr ({wert}): ganze Stufe {draengStufe}");
+ fokus = null;
+ draengBester = null;
+ ohneDraengfortschritt = 0;
+ }
+
+ (int, int, int) stand = (wert.Harte, wert.A, draengStufe == Stufe.B ? wert.B : 0);
+ if (draengBester is not { } bisher || stand.CompareTo(bisher) < 0)
+ {
+ draengBester = stand;
+ ohneDraengfortschritt = 0;
+ }
+ else
+ {
+ ohneDraengfortschritt++;
+ }
+
+ Berechnungsoptionen neu = Draengen(draengStufe);
+ if (neu != Optionen && ohneDraengfortschritt < DraengenOhneFortschritt)
+ {
+ return neu;
+ }
+
+ if (draengStufe == Stufe.A)
+ {
+ // Bei A geht nichts mehr: Stufe B, die A-Gewichte bleiben stehen.
+ draengStufe = Stufe.B;
+ draengBester = null;
+ ohneDraengfortschritt = 0;
+ aenderungen.Add($"{sekunden:0} s bei A geht nichts mehr ({wert}): Stufe B");
+ neu = Draengen(Stufe.B);
+ if (neu != Optionen)
+ {
+ return neu;
+ }
+ }
+
+ // Bei A und B geht nichts mehr. Hat der beste Plan keine C-Verstöße mehr, deren Gewicht steigen kann, gibt es nichts
+ // zu glätten: Die Suche läuft mit den aktuellen Gewichten ruhig weiter.
+ Bewerten(Beste, out _);
+ if (!reihenfolgeC.Any(k => ZieleC(k, Optionen).Length > 0))
+ {
+ return neu;
+ }
+
+ // Sonst C glätten, beginnend beim besten Plan und beim wichtigsten C-Kriterium.
+ phase = Phase.Glaetten;
+ indexC = 0;
+ angehobenC.Clear();
+ bezug = (BesterWert.A, BesterWert.B);
+ NeustartVomBesten = true;
+ aenderungen.Add($"{sekunden:0} s bei A und B geht nichts mehr, bester Plan {BesterWert}: C glätten");
+ return GlaettenSchritt(BesterWert, Optionen);
+ }
+
+ /// Verstöße einer Stufe im zuletzt bewerteten Plan herausdrängen: ihre Gewichte eine Stufe höher.
+ private Berechnungsoptionen Draengen(Stufe stufe)
+ {
+ Berechnungsoptionen neu = Optionen;
+ int[] ziele = Ziele(stufe);
+ var betroffen = ziele.Select(z => z >= 0 ? z % KernDefinition.AnzahlArten : -z - 1).ToHashSet();
+ draengKriterien = einteilung.A.Concat(einteilung.B).Where(k => betroffen.Contains((int)k)).ToArray();
+ foreach (int schluessel in ziele)
+ {
+ neu = Setzen(neu, schluessel, Hoeher(Gewicht(neu, schluessel)));
+ }
+
+ if (ziele.Length > 0)
+ {
+ zaehlerAnhebungen++;
+ Protokoll($"{stufe} angehoben", neu, ziele);
+ }
+
+ return neu;
+ }
+
+ ///
+ /// Glätten: Steigen A oder B im Plan der Suche über den Stand zu Beginn des Kriteriums, wird dessen Anhebung zurückgezogen
+ /// und beim besten Plan mit dem nächsten Kriterium weitergemacht. Sonst steigt für die ausreißenden Mannschaften des
+ /// aktuellen Kriteriums das Gewicht; reißt keine mehr aus, kommt das nächste Kriterium dran.
+ ///
+ private Berechnungsoptionen GlaettenSchritt(Stufenwert wert, Berechnungsoptionen neu)
+ {
+ if (angehobenC.Count > 0 && (wert.A > bezug.A || wert.B > bezug.B + 1))
+ {
+ foreach (int schluessel in angehobenC.Distinct())
+ {
+ neu = Setzen(neu, schluessel, Niedriger(Gewicht(neu, schluessel), Gewicht(original, schluessel)));
+ }
+
+ zurueckgezogen++;
+ aenderungen.Add($"{sekunden:0} s A/B steigen ({wert}): {(Kostenkriterium)reihenfolgeC[indexC]} zurückgezogen");
+ NaechstesKriterium();
+ Bewerten(Beste, out _);
+ }
+
+ while (indexC < reihenfolgeC.Length)
+ {
+ int k = reihenfolgeC[indexC];
+ int[] ziele = ZieleC(k, neu);
+ if (ziele.Length > 0)
+ {
+ foreach (int schluessel in ziele)
+ {
+ neu = Setzen(neu, schluessel, Hoeher(Gewicht(neu, schluessel)));
+ }
+
+ angehobenC.AddRange(ziele);
+ zaehlerAnhebungen++;
+ Protokoll($"C {(Kostenkriterium)k} angehoben", neu, ziele);
+ return neu;
+ }
+
+ NaechstesKriterium();
+ }
+
+ // Alle C-Kriterien durch (geglättet oder zurückgezogen): wieder drängen.
+ aenderungen.Add($"{sekunden:0} s alle C-Kriterien durch: wieder A/B drängen");
+ phase = Phase.Draengen;
+ draengStufe = Stufe.A;
+ draengBester = null;
+ ohneDraengfortschritt = 0;
+ draengKriterien = [];
+ fokus = null;
+ return neu;
+ }
+
+ private void NaechstesKriterium()
+ {
+ indexC++;
+ angehobenC.Clear();
+ bezug = (BesterWert.A, BesterWert.B);
+ NeustartVomBesten = true;
+ }
+
+ ///
+ /// Was beim Glätten eines C-Kriteriums angehoben wird: zuerst die Ausreißer (Spitzen), sonst die Mannschaften mit den
+ /// meisten Verstößen bei diesem Kriterium (Verstöße allgemein reduzieren); bei Plan-Kriterien das ganze Kriterium, solange
+ /// es Verstöße bzw. Kosten hat. Nur Gewichte, die noch steigen können.
+ ///
+ private int[] ZieleC(int k, Berechnungsoptionen optionen)
+ {
+ int[] spitzen = AusreisserSchluessel(k).Where(z => Anhebbar(Gewicht(optionen, z))).ToArray();
+ if (spitzen.Length > 0)
+ {
+ return spitzen;
+ }
+
+ if (k >= KernDefinition.AnzahlArten)
+ {
+ int gesamt = Gesamtschluessel(k);
+ return (anzahl[k] > 0 || (!Stufeneinteilung.HatAnzahl((Kostenkriterium)k) && kosten[k] > 0)) && Anhebbar(Gewicht(optionen, gesamt)) ? [gesamt] : [];
+ }
+
+ int[] mitVerstoss = Enumerable.Range(0, ids.Length)
+ .Select(t => (t * KernDefinition.AnzahlArten) + k)
+ .Where(s => bewerter.KostenAnzahl[s] > 0 && Anhebbar(Gewicht(optionen, s)))
+ .ToArray();
+ if (mitVerstoss.Length == 0)
+ {
+ return [];
+ }
+
+ int meiste = mitVerstoss.Max(s => bewerter.KostenAnzahl[s]);
+ return mitVerstoss.Where(s => bewerter.KostenAnzahl[s] == meiste).ToArray();
+ }
+
+ ///
+ /// Ausreißer eines C-Kriteriums im zuletzt bewerteten Plan: Mannschaften mit mehr als spielraum Verstößen mehr als
+ /// im Grundlauf; bei Kriterien ohne Anzahl (Spieltage) der ganze Plan, wenn die Kosten über der Grenze liegen.
+ ///
+ private IEnumerable AusreisserSchluessel(int k)
+ {
+ if (k >= KernDefinition.AnzahlArten)
+ {
+ if (!Stufeneinteilung.HatAnzahl((Kostenkriterium)k) && kosten[k] > grenzeKosten[k])
+ {
+ yield return Gesamtschluessel(k);
+ }
+
+ yield break;
+ }
+
+ if (anzahl[k] <= 0)
+ {
+ yield break;
+ }
+
+ for (int t = 0; t < ids.Length; t++)
+ {
+ int s = (t * KernDefinition.AnzahlArten) + k;
+ if (bewerter.KostenAnzahl[s] > basisMannschaft[s] + spielraum)
+ {
+ yield return s;
+ }
+ }
+ }
+
+ /// Verstöße der Stufe A bzw. B im zuletzt bewerteten Plan, deren Gewicht noch angehoben werden kann.
+ private int[] Ziele(Stufe stufe)
+ {
+ var ziele = new List();
+ for (int k = 0; k < stufen.Length; k++)
+ {
+ if (stufen[k] != stufe || anzahl[k] <= 0 || (fokus is Kostenkriterium f && (int)f != k))
+ {
+ continue;
+ }
+
+ if (k >= KernDefinition.AnzahlArten)
+ {
+ ziele.Add(Gesamtschluessel(k));
+ continue;
+ }
+
+ for (int t = 0; t < ids.Length; t++)
+ {
+ int s = (t * KernDefinition.AnzahlArten) + k;
+ if (bewerter.KostenAnzahl[s] > 0)
+ {
+ ziele.Add(s);
+ }
+ }
+ }
+
+ return ziele.Where(z => Anhebbar(Gewicht(Optionen, z))).ToArray();
+ }
+
+ private void Protokoll(string was, Berechnungsoptionen optionen, int[] schluessel) =>
+ aenderungen.Add($"{sekunden:0} s {was}: " + string.Join(", ", schluessel.Select(z => Beschreibung(z) + " " + Gewicht(optionen, z))));
+
+ /// Gewicht zu einem Schlüssel: ≥ 0 Mannschaft × Kostenart, < 0 ganzes Kriterium.
+ private Gewichtung Gewicht(Berechnungsoptionen optionen, int schluessel)
+ {
+ if (schluessel < 0)
+ {
+ return (Kostenkriterium)(-schluessel - 1) switch
+ {
+ Kostenkriterium.VereinsinterneSpieleAmAnfang => optionen.VereinsinterneSpieleAmAnfang,
+ Kostenkriterium.Spieltaglaenge => optionen.Spieltag,
+ Kostenkriterium.Spieltagueberlappung => optionen.SpieltagUeberlappung,
+ Kostenkriterium.LetzterSpieltagLaenge => optionen.LetzterSpieltag,
+ Kostenkriterium.LetzterSpieltagUeberlappung => optionen.LetzterSpieltagUeberlappung,
+ Kostenkriterium k => optionen.Fuer((MannschaftsKostenart)(int)k),
+ };
+ }
+
+ string id = ids[schluessel / KernDefinition.AnzahlArten];
+ return optionen.Mannschaften.FirstOrDefault(m => m.MannschaftsId == id)?.JeKostenart[schluessel % KernDefinition.AnzahlArten] ?? Gewichtung.Normal;
+ }
+
+ private Berechnungsoptionen Setzen(Berechnungsoptionen optionen, int schluessel, Gewichtung wert)
+ {
+ if (schluessel < 0)
+ {
+ return (Kostenkriterium)(-schluessel - 1) switch
+ {
+ Kostenkriterium.VereinsinterneSpieleAmAnfang => optionen with { VereinsinterneSpieleAmAnfang = wert },
+ Kostenkriterium.Spieltaglaenge => optionen with { Spieltag = wert },
+ Kostenkriterium.Spieltagueberlappung => optionen with { SpieltagUeberlappung = wert },
+ Kostenkriterium.LetzterSpieltagLaenge => optionen with { LetzterSpieltag = wert },
+ Kostenkriterium.LetzterSpieltagUeberlappung => optionen with { LetzterSpieltagUeberlappung = wert },
+ Kostenkriterium k => GesamtSetzen(optionen, (int)k, wert),
+ };
+ }
+
+ string id = ids[schluessel / KernDefinition.AnzahlArten];
+ var liste = optionen.Mannschaften.ToList();
+ int index = liste.FindIndex(m => m.MannschaftsId == id);
+ MannschaftsGewichtung alt = index >= 0
+ ? liste[index]
+ : new MannschaftsGewichtung(id, Gewichtung.Normal, Enumerable.Repeat(Gewichtung.Normal, Berechnungsoptionen.AnzahlKostenarten).ToArray());
+ Gewichtung[] je = alt.JeKostenart.ToArray();
+ je[schluessel % KernDefinition.AnzahlArten] = wert;
+ MannschaftsGewichtung neu = alt with { JeKostenart = je };
+ if (index >= 0)
+ {
+ liste[index] = neu;
+ }
+ else
+ {
+ liste.Add(neu);
+ }
+
+ return optionen with { Mannschaften = liste };
+ }
+
+ private string Beschreibung(int schluessel) => schluessel < 0
+ ? $"{(Kostenkriterium)(-schluessel - 1)} (alle)"
+ : $"{(MannschaftsKostenart)(schluessel % KernDefinition.AnzahlArten)} {namen[schluessel / KernDefinition.AnzahlArten]}";
+
+ /// Bewertet eine Lösung mit den Gewichtungen des Anwenders je Kriterium und nach Stufen, mit den C-Ausreißern.
+ private Stufenwert Bewerten(Loesung loesung, out double gesamt)
+ {
+ bewerter.LoesungUebernehmen(loesung);
+ bewerter.Zuruecksetzen();
+ bewerter.Sortieren();
+ gesamt = bewerter.Kosten();
+ Stufenwert wert = bewerter.StufenBewerten(stufen, anzahl, kosten);
+ if (Grundlauf)
+ {
+ return wert;
+ }
+
+ int ausreisser = 0;
+ foreach (int k in reihenfolgeC)
+ {
+ ausreisser += AusreisserSchluessel(k).Count();
+ }
+
+ return wert with { Ausreisser = ausreisser };
+ }
+}
diff --git a/src/AndiGenerator.Engine/Inseln/Insel.cs b/src/AndiGenerator.Engine/Inseln/Insel.cs
index a621a31..53ba8e3 100644
--- a/src/AndiGenerator.Engine/Inseln/Insel.cs
+++ b/src/AndiGenerator.Engine/Inseln/Insel.cs
@@ -49,6 +49,9 @@ public Insel(int nummer, Suchplatz[] plaetze, Loesung start, double startKosten)
/// Durchläufe seit dem letzten Neustart (für die Spezial-Insel: seit dem letzten Wechsel der Kostenart).
public long SeitNeustart => durchlaeufe - neustartBei;
+ /// Holt einen Zähler, der sich bei jeder neuen Lösung der Insel (Verbesserung oder Neustart) erhöht (Automodus).
+ public int Stand { get; private set; }
+
/// Anzahl der Neustarts dieser Insel.
public int Neustarts { get; private set; }
@@ -70,6 +73,7 @@ public void Abgleichen()
{
kandidat.KopierenNach(Loesung);
Kosten = kosten;
+ Stand++;
besteStrategie = platz.Strategie;
}
}
@@ -108,6 +112,7 @@ private void Zuruecksetzen(Loesung start, double kosten)
{
start.KopierenNach(Loesung);
Kosten = kosten;
+ Stand++;
verbesserungBei = durchlaeufe;
neustartBei = durchlaeufe;
Neustarts++;
diff --git a/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs b/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs
index bbfadc9..f65c138 100644
--- a/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs
+++ b/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs
@@ -24,20 +24,27 @@ public sealed class Inseloptimierer : IDisposable
private readonly Lock sperre = new();
private readonly Optimierungseinstellungen einstellungen;
- private readonly KernDefinition definition;
private readonly Staffel staffel;
- private readonly Berechnungsoptionen optionen;
private readonly Insel[] inseln;
private readonly Insel? spezial;
private readonly Suchplatz[] alle;
private readonly Loesung beste;
private readonly Loesung leer;
- private readonly KernPlan bewerter;
+ private readonly Automodus? auto;
+ private readonly double autoBudget;
+ private readonly int[] autoStand;
private readonly Random koordinatorZufall;
private readonly ManualResetEventSlim laufen = new(true);
private readonly Stopwatch uhr = new();
+
+ /// Reine Rechenzeit ohne Pausen (für den Automodus, der nach Sekunden lenkt).
+ private readonly Stopwatch laufzeit = new();
private readonly List threads = [];
private CancellationTokenSource? abbruch;
+ private KernDefinition definition;
+ private Berechnungsoptionen optionen;
+ private KernPlan bewerter;
+ private long rechnenZiel;
private double besteKosten;
private int verbesserungen;
private int abgleiche;
@@ -67,6 +74,12 @@ public Inseloptimierer(Staffel staffel, Berechnungsoptionen optionen, Optimierun
definition = new KernDefinition(referenz);
bewerter = NeuerPlan(referenz, definition);
besteKosten = bewerter.Kosten();
+ if (this.einstellungen.Automodus)
+ {
+ autoBudget = this.einstellungen.Zeitbudget > 0 ? this.einstellungen.Zeitbudget : Automodus.StandardBudget;
+ auto = new Automodus(referenz, definition, this.einstellungen);
+ }
+
beste = new Loesung(bewerter.AnzahlErlaubt);
bewerter.LoesungSchreiben(beste);
leer = new Loesung(bewerter.AnzahlErlaubt);
@@ -74,12 +87,14 @@ public Inseloptimierer(Staffel staffel, Berechnungsoptionen optionen, Optimierun
var samen = new Random(this.einstellungen.Startwert ?? Random.Shared.Next());
koordinatorZufall = new Random(samen.Next());
inseln = new Insel[this.einstellungen.Inseln];
+ autoStand = new int[inseln.Length];
+ Array.Fill(autoStand, -1);
for (int i = 0; i < inseln.Length; i++)
{
inseln[i] = InselAnlegen(i + 1, referenz, samen, aktiv: true);
}
- if (this.einstellungen.SpezialInsel)
+ if (this.einstellungen.SpezialInsel && auto is null)
{
spezial = InselAnlegen(0, referenz, samen, aktiv: false);
}
@@ -149,6 +164,18 @@ public MannschaftsKostenart? SpezialKostenart
}
}
+ /// Stand des Automodus; null, wenn er nicht eingeschaltet ist.
+ public Lenkungsstand? Lenkung
+ {
+ get
+ {
+ lock (sperre)
+ {
+ return auto?.Stand;
+ }
+ }
+ }
+
/// Angehalten (Original Paused); die Threads warten ohne Rechenlast.
public bool Pausiert
{
@@ -159,11 +186,17 @@ public bool Pausiert
{
laufen.Reset();
uhr.Stop();
+ laufzeit.Stop();
}
else
{
Volatile.Write(ref durchlaeufeBeiMessbeginn, Durchlaeufe);
uhr.Restart();
+ if (abbruch is not null)
+ {
+ laufzeit.Start();
+ }
+
laufen.Set();
}
}
@@ -172,6 +205,26 @@ public bool Pausiert
/// Fehler, an dem ein Rechen-Thread abgebrochen ist; dann ist die Optimierung beendet.
public Exception? Fehler => Volatile.Read(ref fehler);
+ ///
+ /// Übernimmt eine geänderte Einteilung der Kriterien in den laufenden Automodus (ohne neue Basisoptimierung); die Inseln
+ /// werden danach nach der neuen Einteilung verglichen.
+ ///
+ /// Die neue Einteilung.
+ public void EinteilungAendern(Stufeneinteilung neu)
+ {
+ ArgumentNullException.ThrowIfNull(neu);
+ lock (sperre)
+ {
+ if (auto is null)
+ {
+ return;
+ }
+
+ auto.EinteilungAendern(neu, AutoSekunden());
+ Array.Fill(autoStand, -1);
+ }
+ }
+
/// Startet die Rechen-Threads und den Koordinator im Hintergrund.
public void Starten()
{
@@ -191,6 +244,10 @@ public void Starten()
threads.Add(ThreadStarten(() => Koordinieren(token), "Koordinator", ThreadPriority.Normal));
uhr.Restart();
+ if (!Pausiert)
+ {
+ laufzeit.Start();
+ }
}
/// Beendet alle Threads und übernimmt die letzten Ergebnisse.
@@ -212,6 +269,7 @@ public void Stoppen()
abbruch.Dispose();
abbruch = null;
uhr.Stop();
+ laufzeit.Stop();
lock (sperre)
{
Abgleichen(optimiererSchritt: true);
@@ -232,6 +290,7 @@ public void Rechnen(long durchlaeufe)
}
uhr.Restart();
+ rechnenZiel = durchlaeufe;
long gerechnet = Durchlaeufe;
int index = 0;
while (gerechnet < durchlaeufe)
@@ -264,14 +323,17 @@ public Optimierungsergebnis BesterStand()
{
lock (sperre)
{
- var spiele = new List(beste.Datum.Length);
- for (int s = 0; s < beste.Datum.Length; s++)
+ // Im Automodus nach dem Grundlauf: bester Plan nach Stufen, Kosten mit den Gewichtungen des Anwenders.
+ bool gelenkt = auto is { Grundlauf: false };
+ Loesung ergebnis = gelenkt ? auto!.Beste : beste;
+ var spiele = new List(ergebnis.Datum.Length);
+ for (int s = 0; s < ergebnis.Datum.Length; s++)
{
- DateTime? zeitpunkt = beste.Datum[s] != 0.0 ? DelphiDatum.ZuDateTime(beste.Datum[s]) : null;
+ DateTime? zeitpunkt = ergebnis.Datum[s] != 0.0 ? DelphiDatum.ZuDateTime(ergebnis.Datum[s]) : null;
spiele.Add(new Spiel(zeitpunkt, definition.Name[definition.SpielHeim[s]], definition.Name[definition.SpielGast[s]], string.Empty));
}
- return new Optimierungsergebnis(besteKosten, verbesserungen, spiele);
+ return new Optimierungsergebnis(gelenkt ? auto!.BesteKosten : besteKosten, verbesserungen + (auto?.Verbesserungen ?? 0), spiele);
}
}
@@ -463,6 +525,11 @@ private void Abgleichen(bool optimiererSchritt)
}
}
+ if (auto is not null)
+ {
+ AutoSchritt();
+ }
+
if (spezial is not null && !spezialLaeuft && Durchlaeufe > einstellungen.DurchlaeufeVorSpezialInsel)
{
SpezialNeuAufsetzen(spezial);
@@ -485,4 +552,75 @@ private void Abgleichen(bool optimiererSchritt)
schlechteste.Neustarten(leer, -1);
}
}
+
+ /// Sekunden reiner Rechenzeit für den Automodus (bei fester Zahl von Durchläufen anteilig am Zeitbudget).
+ private double AutoSekunden() => rechnenZiel > 0 ? Durchlaeufe / (double)rechnenZiel * autoBudget : laufzeit.Elapsed.TotalSeconds;
+
+ ///
+ /// Automodus, bei jedem Optimierer-Schritt: Grundlauf beenden, Inseln nach Stufen bewerten und bei Stillstand
+ /// die Gewichte ändern.
+ ///
+ private void AutoSchritt()
+ {
+ // Der Automodus beobachtet die Verstöße über die reine Rechenzeit (ohne Pausen); er kennt keine Zeitgrenzen. Bei einer
+ // festen Zahl von Durchläufen (reproduzierbar) entspricht der ganze Lauf dem Zeitbudget bzw. 300 s.
+ double f = AutoSekunden();
+ auto!.Beobachten(besteKosten, f);
+ if (auto.Grundlauf)
+ {
+ // Nach einem Wechsel von der Kostenoptimierung ist der Plan schon optimiert: gleich mit Stufe A beginnen.
+ if (besteKosten >= 0 && (einstellungen.AutoOhneGrundlauf || auto.GrundlaufVorbei(f)))
+ {
+ auto.GrundlaufBeenden(beste, f, sofortLenken: einstellungen.AutoOhneGrundlauf);
+ }
+
+ return;
+ }
+
+ for (int i = 0; i < inseln.Length; i++)
+ {
+ Insel insel = inseln[i];
+ if (insel.Kosten >= 0 && insel.Stand != autoStand[i])
+ {
+ autoStand[i] = insel.Stand;
+ auto.Pruefen(insel.Loesung);
+ }
+ }
+
+ if (besteKosten >= 0)
+ {
+ auto.Verfolgen(beste, f);
+ }
+
+ if (besteKosten >= 0 && auto.Lenken(beste, f) is { } neu)
+ {
+ GewichteWechseln(neu);
+ }
+ }
+
+ ///
+ /// Neue Gewichtungen für alle Inseln (wie ein Anwender, der während der Rechnung die Gewichte ändert): Jede Insel rechnet
+ /// mit neuen Plänen ab dem besten Plan nach Stufen weiter.
+ ///
+ private void GewichteWechseln(Berechnungsoptionen neu)
+ {
+ optionen = neu;
+ RefPlan referenz = RefPlan.Laden(staffel, neu);
+ var stammdaten = new KernDefinition(referenz);
+ definition = stammdaten;
+ bewerter = NeuerPlan(referenz, stammdaten);
+
+ // Normalerweise rechnet die Suche mit ihrem Plan weiter; beim Glätten beginnt sie beim besten Plan neu.
+ if (auto!.NeustartVomBesten)
+ {
+ auto.Beste.KopierenNach(beste);
+ auto.NeustartVomBesten = false;
+ }
+
+ besteKosten = Bewerten(bewerter, beste);
+ foreach (Insel insel in inseln)
+ {
+ insel.NeuAufsetzen(beste, besteKosten, () => NeuerPlan(referenz, stammdaten));
+ }
+ }
}
diff --git a/src/AndiGenerator.Engine/Inseln/Kostenkriterium.cs b/src/AndiGenerator.Engine/Inseln/Kostenkriterium.cs
new file mode 100644
index 0000000..e0e5cf3
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Kostenkriterium.cs
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+namespace AndiGenerator.Engine.Inseln;
+
+///
+/// Kriterium des Automodus (MIGRATIONSPLAN Abschnitt 11): die 16 Mannschafts-Kostenarten in derselben Reihenfolge
+/// wie , dazu die Kostenarten des ganzen Plans.
+///
+public enum Kostenkriterium
+{
+ /// Hallenbelegung.
+ Hallenbelegung,
+
+ /// Parallele Spiele mit Nachbarmannschaften.
+ ParalleleSpiele,
+
+ /// Gleiche Heimtermine erzwingen.
+ GleicheHeimtermine,
+
+ /// Sperrtermine.
+ Sperrtermine,
+
+ /// Ausweichtermine.
+ Ausweichtermine,
+
+ /// 60-km-Regel.
+ SechzigKilometerRegel,
+
+ /// 3-Tage-Abstand.
+ DreiTageAbstand,
+
+ /// 2 Spiele pro Woche.
+ ZweiSpieleProWoche,
+
+ /// Spielverteilung.
+ Spielverteilung,
+
+ /// Heim-Koppeltermine.
+ Heimkoppel,
+
+ /// Auswärtskoppel.
+ Auswaertskoppel,
+
+ /// Ungleich Heim/Auswärts.
+ UngleichHeimAuswaerts,
+
+ /// Wechsel Heim/Auswärts.
+ WechselHeimAuswaerts,
+
+ /// Abstand Heim/Auswärts.
+ AbstandHeimAuswaerts,
+
+ /// Setzliste.
+ Setzliste,
+
+ /// Pflichtspieltage.
+ Pflichtspieltage,
+
+ /// Vereinsinterne Spiele am Anfang (ganzer Plan; Anzahl 1, wenn verletzt).
+ VereinsinterneSpieleAmAnfang,
+
+ /// Länge der Spieltage (ganzer Plan, nur Kosten).
+ Spieltaglaenge,
+
+ /// Überlappung der Spieltage (ganzer Plan, nur Kosten).
+ Spieltagueberlappung,
+
+ /// Länge des letzten Spieltags (ganzer Plan, nur Kosten).
+ LetzterSpieltagLaenge,
+
+ /// Überlappung des letzten Spieltags (ganzer Plan, nur Kosten).
+ LetzterSpieltagUeberlappung,
+}
diff --git a/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs b/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs
new file mode 100644
index 0000000..869af59
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+using AndiGenerator.Domain.Optionen;
+
+namespace AndiGenerator.Engine.Inseln;
+
+/// Stand des Automodus (MIGRATIONSPLAN Abschnitt 11).
+/// Bewertung des besten Plans am Ende des Grundlaufs; null, solange der Grundlauf läuft.
+/// Bewertung des besten Plans nach Stufen.
+/// Bewertung des Plans, an dem die Suche gerade arbeitet (bester nach den aktuellen Gewichten).
+/// Grundlauf, Drängen oder Glätten (mit dem aktuellen C-Kriterium), für Protokolle.
+/// Phase für die Oberfläche: „Basisoptimierung“, „Optimierung Stufe A“, „… Stufe B“ oder „… Stufe C“.
+/// Wie oft Gewichte angehoben wurden.
+/// Wie oft das Glätten eines C-Kriteriums zurückgezogen wurde, weil A oder B stiegen.
+/// Die aktuell geltenden Gewichtungen (vom Automodus angepasst).
+/// Die Änderungen mit Zeitpunkt, z. B. „42 s angehoben: Hallenbelegung TSV A 1 Hoch“.
+///
+/// Die Kriterien, an denen gerade gearbeitet wird, in der Reihenfolge der Einteilung: in Stufe A und B die zuletzt
+/// angehobenen, in Stufe C das, das geglättet wird; leer in der Basisoptimierung.
+///
+public sealed record Lenkungsstand(
+ Stufenwert? Basis,
+ Stufenwert Bester,
+ Stufenwert? Suche,
+ string Phase,
+ string Bezeichnung,
+ int Anhebungen,
+ int Zurueckgezogen,
+ Berechnungsoptionen Gewichtungen,
+ IReadOnlyList Aenderungen,
+ IReadOnlyList? Kriterien = null);
diff --git a/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs b/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs
index 26bfb93..e87f27d 100644
--- a/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs
+++ b/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs
@@ -15,6 +15,24 @@ namespace AndiGenerator.Engine.Inseln;
/// Gesamtdurchläufe, ab denen die Spezial-Insel startet (Original 3 000 000).
/// Durchläufe der Spezial-Insel seit dem letzten Wechsel der Kostenart, bevor ihre Lösung eine Insel neu startet (Original 200 000; Befund #10 korrigiert).
/// Startwert der Zufallsfolgen; null = zufällig. Nur ist damit reproduzierbar.
+///
+/// Für mit Automodus: Sekunden, denen der ganze Lauf entspricht (der Automodus beobachtet
+/// Sekunden reiner Rechenzeit; reproduzierbar werden sie aus den Durchläufen abgeleitet); 0 = 300 s.
+///
+///
+/// Automodus: Nach der Basisoptimierung (Grundlauf mit den Gewichtungen des Anwenders, bis die Suche 10 s keine
+/// Verbesserung findet, mindestens 10 s) lenkt die Suche über die Gewichte: erst A-, dann B-Verstöße herausdrängen, dann
+/// die C-Kriterien nach Wichtigkeit je Mannschaft glätten, bis A oder B wieder steigen. Ohne Zeitgrenzen: Der Automodus
+/// beobachtet, ob sich die Verstöße nach einer Gewichtsänderung noch ändern (10 s ohne Verbesserung, frühestens 8 s nach der letzten Änderung = nächster Schritt).
+/// Die Spezial-Insel ist dabei aus.
+///
+/// Verstöße, die eine Mannschaft je C-Kriterium mehr haben darf als im Grundlauf, bevor sie als Ausreißer gilt.
+/// Erlaubter Anstieg der Kosten der Spieltagskriterien (ohne Anzahl) gegenüber dem Grundlauf (0,5 = 50 %, mindestens 2 % der C-Kosten).
+/// Einteilung der Kriterien in A, B und C mit der Wichtigkeit der C-Kriterien; null = .
+///
+/// Automodus ohne Basisoptimierung: Der Ausgangsplan ist schon optimiert (Wechsel von der Kostenoptimierung), sein bester
+/// Plan wird sofort Maßstab, und die Optimierung der Stufe A beginnt gleich.
+///
public sealed record Optimierungseinstellungen(
int Kerne,
int Inseln,
@@ -25,7 +43,13 @@ public sealed record Optimierungseinstellungen(
bool SpezialInsel,
long DurchlaeufeVorSpezialInsel,
long MindestDurchlaeufeSpezialInsel,
- int? Startwert)
+ int? Startwert,
+ double Zeitbudget = 0,
+ bool Automodus = false,
+ int AutoSpielraum = 2,
+ double AutoKostenToleranz = 0.5,
+ Stufeneinteilung? AutoStufen = null,
+ bool AutoOhneGrundlauf = false)
{
/// Standard: Topologie des Originals (4 Inseln × 14 feste + 10 dynamische Suchplätze, Spezial-Insel), alle Kerne bis auf einen.
public static Optimierungseinstellungen Standard { get; } = new(
diff --git a/src/AndiGenerator.Engine/Inseln/Stufe.cs b/src/AndiGenerator.Engine/Inseln/Stufe.cs
new file mode 100644
index 0000000..71e7561
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Stufe.cs
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+namespace AndiGenerator.Engine.Inseln;
+
+/// Stufe eines Kriteriums im Automodus (E14): A muss, B soll, C ist wichtig und darf nicht geopfert werden.
+public enum Stufe
+{
+ /// Stufe A: Kollisionen werden zuerst herausgedrängt.
+ A,
+
+ /// Stufe B: danach.
+ B,
+
+ /// Stufe C: wird bewacht; kein Kriterium darf aus dem Ruder laufen.
+ C,
+}
diff --git a/src/AndiGenerator.Engine/Inseln/Stufeneinteilung.cs b/src/AndiGenerator.Engine/Inseln/Stufeneinteilung.cs
new file mode 100644
index 0000000..e6833e1
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Stufeneinteilung.cs
@@ -0,0 +1,241 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+using AndiGenerator.Engine.Referenz;
+
+namespace AndiGenerator.Engine.Inseln;
+
+///
+/// Einteilung der Kriterien in die Stufen A, B und C für den Automodus; der Staffelleiter legt sie selbst fest (Vorgabe
+/// Peter). Die Reihenfolge innerhalb von C ist die Wichtigkeit: Beim Glätten wird beim ersten C-Kriterium begonnen. Nicht
+/// genannte Kriterien gehören zu C und kommen nach den genannten in der Reihenfolge von . Kriterien ohne
+/// Anzahl (Spieltagslänge und -überlappung) gehören immer zu C.
+///
+/// Kriterien der Stufe A.
+/// Kriterien der Stufe B.
+/// Kriterien der Stufe C, wichtigstes zuerst.
+public sealed record Stufeneinteilung(IReadOnlyList A, IReadOnlyList B, IReadOnlyList C)
+{
+ /// Anzahl der Kriterien.
+ public static readonly int AnzahlKriterien = Enum.GetValues().Length;
+
+ ///
+ /// Gets die Einteilung nach E14: A Hallenbelegung, parallele Spiele, Pflichtspieltage, vereinsinterne Spiele am Anfang;
+ /// B Auswärts- und Heimkoppel, Sperr- und Ausweichtermine; C nach Wichtigkeit: Heim/Auswärts ausgeglichen, Wechsel,
+ /// Abstände, Spielverteilung, übrige, zuletzt die Spieltage.
+ ///
+ public static Stufeneinteilung Standard { get; } = new(
+ [Kostenkriterium.Hallenbelegung, Kostenkriterium.ParalleleSpiele, Kostenkriterium.Pflichtspieltage, Kostenkriterium.VereinsinterneSpieleAmAnfang],
+ [Kostenkriterium.Auswaertskoppel, Kostenkriterium.Heimkoppel, Kostenkriterium.Sperrtermine, Kostenkriterium.Ausweichtermine],
+ [
+ Kostenkriterium.UngleichHeimAuswaerts, Kostenkriterium.WechselHeimAuswaerts, Kostenkriterium.DreiTageAbstand,
+ Kostenkriterium.ZweiSpieleProWoche, Kostenkriterium.Spielverteilung, Kostenkriterium.AbstandHeimAuswaerts,
+ Kostenkriterium.GleicheHeimtermine,
+ Kostenkriterium.SechzigKilometerRegel, Kostenkriterium.Setzliste, Kostenkriterium.Spieltaglaenge,
+ Kostenkriterium.Spieltagueberlappung, Kostenkriterium.LetzterSpieltagLaenge, Kostenkriterium.LetzterSpieltagUeberlappung,
+ ]);
+
+ /// Liest eine Einteilung wie A:Hallenbelegung,ParalleleSpiele;B:Sperrtermine;C:DreiTageAbstand,Spielverteilung.
+ /// Der Text; nicht genannte Kriterien gehören zu C.
+ /// Die Einteilung; null, wenn der Text ungültig ist.
+ public static Stufeneinteilung? Lesen(string text)
+ {
+ var listen = new List[] { [], [], [] };
+ foreach (string teil in text.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ string[] paar = teil.Split(':', 2);
+ if (paar.Length != 2 || !Enum.TryParse(paar[0].Trim(), true, out Stufe stufe) || !Enum.IsDefined(stufe))
+ {
+ return null;
+ }
+
+ foreach (string name in paar[1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (!Enum.TryParse(name, true, out Kostenkriterium k) || !Enum.IsDefined(k) || listen.Any(l => l.Contains(k)))
+ {
+ return null;
+ }
+
+ listen[(int)stufe].Add(k);
+ if (!HatAnzahl(k) && stufe != Stufe.C)
+ {
+ return null;
+ }
+ }
+ }
+
+ return new Stufeneinteilung(listen[0], listen[1], listen[2]).Vervollstaendigt();
+ }
+
+ /// Liest eine gespeicherte Einteilung; leer oder ungültig ergibt den .
+ /// Der Text, z. B. aus den Spielplandaten.
+ /// Die vollständige Einteilung.
+ public static Stufeneinteilung AusText(string? text) =>
+ string.IsNullOrWhiteSpace(text) ? Standard : (Lesen(text) ?? Standard);
+
+ /// Kriterien mit einer Anzahl von Verstößen (alle außer Spieltagslänge und -überlappung).
+ /// Das Kriterium.
+ /// true, wenn es eine Anzahl hat.
+ public static bool HatAnzahl(Kostenkriterium k) => k <= Kostenkriterium.VereinsinterneSpieleAmAnfang;
+
+ /// Text zum Speichern, z. B. A:Hallenbelegung,ParalleleSpiele;B:Sperrtermine;C:…; mit lesbar.
+ /// Der Text.
+ public string Text() => $"A:{string.Join(',', A)};B:{string.Join(',', B)};C:{string.Join(',', Vervollstaendigt().C)}";
+
+ /// Stufe eines Kriteriums.
+ /// Das Kriterium.
+ /// A, B oder C.
+ public Stufe StufeVon(Kostenkriterium k)
+ {
+ if (A.Contains(k))
+ {
+ return Stufe.A;
+ }
+
+ return B.Contains(k) ? Stufe.B : Stufe.C;
+ }
+
+ ///
+ /// Stuft ein Kriterium um: Rückt es nach unten (A → B, B → C), kommt es an die erste Stelle der neuen Stufe, rückt es
+ /// nach oben (C → B, B → A), ans Ende. Kriterien ohne Anzahl (Spieltage) bleiben in C.
+ ///
+ /// Das Kriterium.
+ /// Die neue Stufe.
+ /// Die neue Einteilung (unverändert, wenn nicht möglich).
+ public Stufeneinteilung Einstufen(Kostenkriterium k, Stufe stufe)
+ {
+ if ((!HatAnzahl(k) && stufe != Stufe.C) || StufeVon(k) == stufe)
+ {
+ return this;
+ }
+
+ bool nachUnten = stufe > StufeVon(k);
+ var listen = new[] { A.Where(x => x != k).ToList(), B.Where(x => x != k).ToList(), Vervollstaendigt().C.Where(x => x != k).ToList() };
+ listen[(int)stufe].Insert(nachUnten ? 0 : listen[(int)stufe].Count, k);
+ return new Stufeneinteilung(listen[0], listen[1], listen[2]);
+ }
+
+ ///
+ /// Setzt ein Kriterium direkt vor oder hinter ein anderes (Ziehen und Ablegen): Es übernimmt dessen Stufe. Kriterien
+ /// ohne Anzahl (Spieltage) bleiben in C.
+ ///
+ /// Das Kriterium.
+ /// Das Kriterium, neben das es kommt.
+ /// true = davor, sonst dahinter.
+ /// Die neue Einteilung (unverändert, wenn nicht möglich).
+ public Stufeneinteilung Platzieren(Kostenkriterium k, Kostenkriterium nachbar, bool davor)
+ {
+ Stufeneinteilung voll = Vervollstaendigt();
+ Stufe ziel = voll.StufeVon(nachbar);
+ if (k == nachbar || (!HatAnzahl(k) && ziel != Stufe.C))
+ {
+ return this;
+ }
+
+ var listen = new[] { voll.A.Where(x => x != k).ToList(), voll.B.Where(x => x != k).ToList(), voll.C.Where(x => x != k).ToList() };
+ List liste = listen[(int)ziel];
+ int index = liste.IndexOf(nachbar);
+ liste.Insert(davor ? index : index + 1, k);
+ return new Stufeneinteilung(listen[0], listen[1], listen[2]);
+ }
+
+ /// Position eines Kriteriums innerhalb seiner Stufe (0 = wichtigstes).
+ /// Das Kriterium.
+ /// Die Position.
+ public int Position(Kostenkriterium k)
+ {
+ Stufeneinteilung voll = Vervollstaendigt();
+ return voll.StufeVon(k) switch
+ {
+ Stufe.A => voll.A.ToList().IndexOf(k),
+ Stufe.B => voll.B.ToList().IndexOf(k),
+ _ => voll.C.ToList().IndexOf(k),
+ };
+ }
+
+ /// Verschiebt ein Kriterium innerhalb seiner Stufe um eine Stelle nach oben (−1) oder unten (+1).
+ /// Das Kriterium.
+ /// −1 oder +1.
+ /// Die neue Einteilung (unverändert am Rand der Stufe).
+ public Stufeneinteilung Verschieben(Kostenkriterium k, int richtung)
+ {
+ Stufeneinteilung voll = Vervollstaendigt();
+ var listen = new[] { voll.A.ToList(), voll.B.ToList(), voll.C.ToList() };
+ List liste = listen[(int)voll.StufeVon(k)];
+ int alt = liste.IndexOf(k);
+ int neu = alt + Math.Sign(richtung);
+ if (alt < 0 || neu < 0 || neu >= liste.Count)
+ {
+ return this;
+ }
+
+ (liste[alt], liste[neu]) = (liste[neu], liste[alt]);
+ return new Stufeneinteilung(listen[0], listen[1], listen[2]);
+ }
+
+ /// Kriterien mit einer Anzahl von Verstößen (alle außer Spieltagslänge und -überlappung).
+ /// Stufe je Kriterium, Index = Enumwert.
+ public Stufe[] AlsFeld()
+ {
+ var stufen = new Stufe[AnzahlKriterien];
+ Array.Fill(stufen, Stufe.C);
+ foreach (Kostenkriterium k in A)
+ {
+ stufen[(int)k] = Stufe.A;
+ }
+
+ foreach (Kostenkriterium k in B)
+ {
+ stufen[(int)k] = Stufe.B;
+ }
+
+ return stufen;
+ }
+
+ ///
+ /// Zählt die Verstöße einer Bewertung nach dieser Einteilung (wie der Automodus): Anzahlen je Mannschafts-Kostenart über
+ /// alle Mannschaften, vereinsinterne Spiele am Anfang als 1; Spieltagslänge und -überlappung zählen nur bei den C-Kosten.
+ ///
+ /// Die Bewertung eines Plans.
+ /// Harte Fehler, Verstöße A, B und C, Kosten der Stufe C (ohne Ausreißer).
+ public Stufenwert Zaehlen(Planbewertung bewertung)
+ {
+ ArgumentNullException.ThrowIfNull(bewertung);
+ Stufe[] stufen = AlsFeld();
+ double[] anzahl = new double[AnzahlKriterien];
+ double[] kosten = new double[AnzahlKriterien];
+ for (int k = 0; k < (int)Kostenkriterium.VereinsinterneSpieleAmAnfang; k++)
+ {
+ anzahl[k] = bewertung.Mannschaften.Sum(m => Math.Max(0, m.JeKostenart[k].Anzahl));
+ kosten[k] = bewertung.Mannschaften.Sum(m => m.JeKostenart[k].Kosten);
+ }
+
+ anzahl[(int)Kostenkriterium.VereinsinterneSpieleAmAnfang] = bewertung.VereinsinterneSpieleAmAnfang > 0 ? 1 : 0;
+ kosten[(int)Kostenkriterium.VereinsinterneSpieleAmAnfang] = bewertung.VereinsinterneSpieleAmAnfang;
+ kosten[(int)Kostenkriterium.Spieltaglaenge] = bewertung.LaengeSpieltage;
+ kosten[(int)Kostenkriterium.Spieltagueberlappung] = bewertung.UeberlappungSpieltage;
+ kosten[(int)Kostenkriterium.LetzterSpieltagLaenge] = bewertung.LaengeLetzterSpieltag;
+ kosten[(int)Kostenkriterium.LetzterSpieltagUeberlappung] = bewertung.UeberlappungLetzterSpieltag;
+
+ double[] jeStufe = new double[3];
+ double c = 0;
+ for (int k = 0; k < AnzahlKriterien; k++)
+ {
+ jeStufe[(int)stufen[k]] += anzahl[k];
+ c += stufen[k] == Stufe.C ? kosten[k] : 0;
+ }
+
+ int harte = (int)Math.Round((bewertung.NichtTerminiert + bewertung.UngueltigeSpiele + bewertung.SpieleAnSpielfreienTagen) / (10.0 * RefPlan.MaxKostenOhneHartenFehler));
+ return new Stufenwert(harte, (int)jeStufe[0], (int)jeStufe[1], 0, (int)jeStufe[2], c);
+ }
+
+ /// Alle Kriterien der Stufe C nach Wichtigkeit: die genannten, dann die übrigen in der Reihenfolge des Standards.
+ /// Die Einteilung mit vollständiger C-Liste.
+ public Stufeneinteilung Vervollstaendigt()
+ {
+ IEnumerable rest = Standard.C.Concat(Standard.A).Concat(Standard.B).Concat(Enum.GetValues());
+ List c = C.Concat(rest).Distinct().Where(k => !A.Contains(k) && !B.Contains(k)).ToList();
+ return this with { C = c };
+ }
+}
diff --git a/src/AndiGenerator.Engine/Inseln/Stufenwert.cs b/src/AndiGenerator.Engine/Inseln/Stufenwert.cs
new file mode 100644
index 0000000..236f199
--- /dev/null
+++ b/src/AndiGenerator.Engine/Inseln/Stufenwert.cs
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+namespace AndiGenerator.Engine.Inseln;
+
+///
+/// Bewertung eines Plans im Automodus (MIGRATIONSPLAN Abschnitt 11), immer mit den Gewichtungen des Anwenders:
+/// Zahl der harten Fehler, der Verstöße der Stufen A und B (E14), der C-Ausreißer (Mannschaften, die bei einem C-Kriterium
+/// deutlich schlechter dastehen als im Grundlauf) und der C-Verstöße, zuletzt die C-Kosten. Verglichen wird in dieser
+/// Reihenfolge; die Kosten entscheiden erst bei gleichen Anzahlen.
+///
+/// Nicht terminierte, nicht erlaubte Spiele und Spiele an spielfreien Tagen.
+/// Verstöße der Stufe A (Anzahlen über alle Mannschaften, ein Plan-Kriterium zählt 1).
+/// Verstöße der Stufe B.
+/// C-Ausreißer: Mannschaften × C-Kriterien über dem erlaubten Spielraum (ohne Grundlauf: 0).
+/// Verstöße der Kriterien der Stufe C (Spieltagslänge und -überlappung haben keine Anzahl).
+/// Kosten der Kriterien der Stufe C.
+public readonly record struct Stufenwert(int Harte, int A, int B, int Ausreisser, int CAnzahl, double C)
+{
+ /// Weniger harte Fehler, dann weniger A-, B-Verstöße, C-Ausreißer und C-Verstöße, bei gleichen Anzahlen kleinere C-Kosten.
+ /// Der Vergleichswert.
+ /// true, wenn dieser Wert besser ist.
+ public bool IstBesserAls(Stufenwert andere)
+ {
+ int vergleich = (Harte, A, B, Ausreisser, CAnzahl).CompareTo((andere.Harte, andere.A, andere.B, andere.Ausreisser, andere.CAnzahl));
+ return vergleich != 0 ? vergleich < 0 : C < andere.C;
+ }
+
+ ///
+ public override string ToString() =>
+ (Harte > 0 ? $"H {Harte}, " : string.Empty) + $"A {A}, B {B}, C {CAnzahl} ({C:N0})" + (Ausreisser > 0 ? $", C-Ausreißer {Ausreisser}" : string.Empty);
+}
diff --git a/src/AndiGenerator.Engine/Kern/KernPlan.Stufen.cs b/src/AndiGenerator.Engine/Kern/KernPlan.Stufen.cs
new file mode 100644
index 0000000..00e7c71
--- /dev/null
+++ b/src/AndiGenerator.Engine/Kern/KernPlan.Stufen.cs
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+using AndiGenerator.Domain.Optionen;
+using AndiGenerator.Engine.Inseln;
+using AndiGenerator.Engine.Referenz;
+
+namespace AndiGenerator.Engine.Kern;
+
+/// Bewertung nach Kriterien und Stufen für den Automodus (MIGRATIONSPLAN Abschnitt 11).
+internal sealed partial class KernPlan
+{
+ ///
+ /// Bewertet den aktuellen Stand je Kriterium (Anzahl der Verstöße und Kosten) und fasst nach Stufen zusammen; danach
+ /// stehen in die Verstöße je Mannschaft.
+ ///
+ /// Stufe je Kriterium (Index = ).
+ /// Wird gefüllt: Verstöße je Kriterium.
+ /// Wird gefüllt: Kosten je Kriterium.
+ /// Harte Fehler, Verstöße A, B und C, Kosten der Stufe C.
+ public Stufenwert StufenBewerten(Stufe[] stufen, double[] anzahl, double[] kosten)
+ {
+ Sortieren();
+ Kosten();
+ double hart = FehlterminKosten() + SpielfreieTageKosten() + NichtErlaubteKosten();
+ int harte = (int)Math.Round(hart / (10.0 * RefPlan.MaxKostenOhneHartenFehler));
+ Array.Clear(anzahl);
+ Array.Clear(kosten);
+ Berechnungsoptionen optionen = D.Optionen;
+ foreach (MannschaftsKostenart art in Enum.GetValues())
+ {
+ if (optionen.Fuer(art) == Gewichtung.NichtBeruecksichtigen)
+ {
+ continue;
+ }
+
+ for (int t = 0; t < D.N; t++)
+ {
+ int k = (t * KernDefinition.AnzahlArten) + (int)art;
+ anzahl[(int)art] += Math.Max(0, KostenAnzahl[k]);
+ kosten[(int)art] += KostenWert[k];
+ }
+ }
+
+ if (optionen.VereinsinterneSpieleAmAnfang != Gewichtung.NichtBeruecksichtigen)
+ {
+ double vereinsintern = VereinsinterneSpieleAmAnfangKosten();
+ kosten[(int)Kostenkriterium.VereinsinterneSpieleAmAnfang] = vereinsintern;
+ anzahl[(int)Kostenkriterium.VereinsinterneSpieleAmAnfang] = vereinsintern > 0 ? 1 : 0;
+ }
+
+ if (optionen.Spieltag != Gewichtung.NichtBeruecksichtigen || optionen.SpieltagUeberlappung != Gewichtung.NichtBeruecksichtigen)
+ {
+ RefPlan.Spieltagskosten s = SpieltagsKosten();
+ kosten[(int)Kostenkriterium.Spieltaglaenge] = s.Breite;
+ kosten[(int)Kostenkriterium.Spieltagueberlappung] = s.Ueberlappung;
+ kosten[(int)Kostenkriterium.LetzterSpieltagLaenge] = s.LetzteBreite;
+ kosten[(int)Kostenkriterium.LetzterSpieltagUeberlappung] = s.LetzteUeberlappung;
+ }
+
+ double[] jeStufe = new double[3];
+ double kostenC = 0;
+ for (int k = 0; k < stufen.Length; k++)
+ {
+ jeStufe[(int)stufen[k]] += anzahl[k];
+ if (stufen[k] == Stufe.C)
+ {
+ kostenC += kosten[k];
+ }
+ }
+
+ return new Stufenwert(harte, (int)jeStufe[0], (int)jeStufe[1], 0, (int)jeStufe[2], kostenC);
+ }
+
+ /// Vereinsinterne Spiele liegen nicht alle am Anfang (und die Kostenart wird berücksichtigt).
+ /// true bei einem Verstoß.
+ public bool VereinsinternVerletzt() =>
+ D.Optionen.VereinsinterneSpieleAmAnfang != Gewichtung.NichtBeruecksichtigen && TeamsNichtAmAnfang() > 0;
+}
diff --git a/src/AndiGenerator.Engine/Referenz/Mannschaftsmeldungen.cs b/src/AndiGenerator.Engine/Referenz/Mannschaftsmeldungen.cs
new file mode 100644
index 0000000..5e8e309
--- /dev/null
+++ b/src/AndiGenerator.Engine/Referenz/Mannschaftsmeldungen.cs
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+namespace AndiGenerator.Engine.Referenz;
+
+/// Meldungen einer Mannschaft zu einer Kostenart.
+/// Name der Mannschaft.
+/// Die Meldungen; leer, wenn keine anfallen.
+public sealed record Mannschaftsmeldungen(string Mannschaft, IReadOnlyList Meldungen);
diff --git a/src/AndiGenerator.Engine/Referenz/Referenzbewertung.cs b/src/AndiGenerator.Engine/Referenz/Referenzbewertung.cs
index 3002740..1eee7d0 100644
--- a/src/AndiGenerator.Engine/Referenz/Referenzbewertung.cs
+++ b/src/AndiGenerator.Engine/Referenz/Referenzbewertung.cs
@@ -97,4 +97,28 @@ public static IReadOnlyList Meldungen(Staffel staffel, Berechnungsoption
mannschaft.KostenFuerArt(plan, art, kosten, meldungen: true);
return kosten.Meldungen.ToList();
}
+
+ ///
+ /// Meldungen aller Mannschaften zu einer einzelnen Kostenart (wie , aber mit einmaligem Laden des
+ /// Plans; für die Einzelheiten eines Kriteriums in der Qualitätsansicht).
+ ///
+ /// Staffel mit dem zu bewertenden Plan als bestehendem Spielplan.
+ /// Berechnungsoptionen.
+ /// Kostenart.
+ /// Je Mannschaft (in der Reihenfolge der Bewertung) ihre Meldungen.
+ public static IReadOnlyList MeldungenJeMannschaft(Staffel staffel, Berechnungsoptionen optionen, MannschaftsKostenart art)
+ {
+ ArgumentNullException.ThrowIfNull(staffel);
+ ArgumentNullException.ThrowIfNull(optionen);
+ RefPlan plan = RefPlan.Laden(staffel, optionen);
+ var liste = new List();
+ foreach (RefMannschaft mannschaft in plan.Mannschaften)
+ {
+ var kosten = new RefMannschaftsKosten();
+ mannschaft.KostenFuerArt(plan, art, kosten, meldungen: true);
+ liste.Add(new Mannschaftsmeldungen(mannschaft.TeamName, kosten.Meldungen.ToList()));
+ }
+
+ return liste;
+ }
}
diff --git a/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs b/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs
index 0e21307..0fe8ff4 100644
--- a/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs
+++ b/src/AndiGenerator.Persistence/Plandaten/StaffelAbbildung.cs
@@ -17,6 +17,9 @@ namespace AndiGenerator.Persistence.Plandaten;
///
public static class StaffelAbbildung
{
+ /// Attribut am Knoten plan mit der Einteilung der Kriterien (nicht im Original, dort ohne Wirkung).
+ public const string KriterienAttribut = "kriterienstufen";
+
/// Liest die Stammdaten aus dem Datenbaum.
/// Wurzelknoten plan (click-TT-Import, ggf. mit eingearbeiteten Änderungen).
/// Die Staffel.
@@ -39,7 +42,10 @@ public static Staffel AusPlanDaten(DatenKnoten plan)
SpielfreieTage: plan.KinderMitNamen("nogameday").Select(k => Datum(k.Lesen("date"))).ToList(),
Pflichtspielzeitraeume: plan.KinderMitNamen("mandatorygames").Select(PflichtspielzeitraumLesen).ToList(),
Setzliste: ranking is null ? null : SetzlisteLesen(ranking),
- BestehenderSpielplan: SpieleLesen(plan.ErstesKind("existingschedule")));
+ BestehenderSpielplan: SpieleLesen(plan.ErstesKind("existingschedule")))
+ {
+ Kriterienstufen = plan.Lesen(KriterienAttribut),
+ };
}
catch (FormatException ex)
{
@@ -60,6 +66,10 @@ public static DatenKnoten NachPlanDaten(Staffel staffel)
plan.Setzen("until", DatumText(staffel.Ende));
plan.Setzen("name", staffel.Name);
plan.Setzen("id", staffel.Id);
+ if (staffel.Kriterienstufen.Length > 0)
+ {
+ plan.Setzen(KriterienAttribut, staffel.Kriterienstufen);
+ }
foreach (Mannschaft mannschaft in staffel.Mannschaften)
{
diff --git a/src/AndiGenerator.Persistence/Tabellen/Arbeitsblatt.cs b/src/AndiGenerator.Persistence/Tabellen/Arbeitsblatt.cs
deleted file mode 100644
index 403ca58..0000000
--- a/src/AndiGenerator.Persistence/Tabellen/Arbeitsblatt.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-namespace AndiGenerator.Persistence.Tabellen;
-
-/// Arbeitsblatt einer Arbeitsmappe.
-/// Name des Blatts; unzulässige Zeichen werden beim Speichern ersetzt, zu lange Namen gekürzt.
-/// Die Zeilen, jede mit ihren Zellen von links.
-///
-/// Index der Kopfzeile einer Liste oder null: Sie bleibt beim Blättern stehen und erhält einen Filter über alle
-/// folgenden Zeilen.
-///
-public sealed record Arbeitsblatt(string Name, IReadOnlyList> Zeilen, int? Kopfzeile = null);
diff --git a/src/AndiGenerator.Persistence/Tabellen/Arbeitsmappe.cs b/src/AndiGenerator.Persistence/Tabellen/Arbeitsmappe.cs
deleted file mode 100644
index 808c8fd..0000000
--- a/src/AndiGenerator.Persistence/Tabellen/Arbeitsmappe.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-namespace AndiGenerator.Persistence.Tabellen;
-
-/// Arbeitsmappe (Excel-Datei) aus mehreren Blättern.
-/// Die Blätter in ihrer Reihenfolge.
-public sealed record Arbeitsmappe(IReadOnlyList Blaetter);
diff --git a/src/AndiGenerator.Persistence/Tabellen/Blattzelle.cs b/src/AndiGenerator.Persistence/Tabellen/Blattzelle.cs
deleted file mode 100644
index b02dd09..0000000
--- a/src/AndiGenerator.Persistence/Tabellen/Blattzelle.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-namespace AndiGenerator.Persistence.Tabellen;
-
-/// Zelle eines Arbeitsblatts.
-///
-/// Inhalt: , , , (Datum),
-/// (Uhrzeit) oder null für eine leere Zelle.
-///
-/// Zahlenformat; für Datum und Uhrzeit passend setzen.
-/// Fettschrift.
-/// Hintergrundfarbe als 0xRRGGBB oder null.
-/// Schriftfarbe als 0xRRGGBB oder null (schwarz).
-public sealed record Blattzelle(object? Wert, Zellformat Format = Zellformat.Standard, bool Fett = false, int? Hintergrund = null, int? Schriftfarbe = null)
-{
- /// Holt eine leere Zelle.
- public static Blattzelle Leer { get; } = new((object?)null);
-
- /// Erzeugt eine Textzelle.
- /// Der Text.
- /// Fettschrift.
- /// Die Zelle.
- public static Blattzelle Text(string text, bool fett = false) => new(text, Fett: fett);
-
- /// Erzeugt eine Datumszelle (leer ohne Datum).
- /// Das Datum oder null.
- /// Die Zelle.
- public static Blattzelle Datum(DateTime? datum) => new(datum?.Date, Zellformat.Datum);
-
- /// Erzeugt eine Uhrzeitzelle (leer ohne Zeitpunkt).
- /// Der Zeitpunkt oder null.
- /// Die Zelle.
- public static Blattzelle Uhrzeit(DateTime? zeitpunkt) => new(zeitpunkt?.TimeOfDay, Zellformat.Uhrzeit);
-
- /// Erzeugt eine Zelle mit einer ganzen Zahl.
- /// Die Zahl.
- /// Die Zelle.
- public static Blattzelle Zahl(int zahl) => new(zahl, Zellformat.Ganzzahl);
-}
diff --git a/src/AndiGenerator.Persistence/Tabellen/XlsxDatei.cs b/src/AndiGenerator.Persistence/Tabellen/XlsxDatei.cs
deleted file mode 100644
index e53749f..0000000
--- a/src/AndiGenerator.Persistence/Tabellen/XlsxDatei.cs
+++ /dev/null
@@ -1,532 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-using System.Globalization;
-using System.IO.Compression;
-using System.Text;
-using System.Xml;
-
-namespace AndiGenerator.Persistence.Tabellen;
-
-///
-/// Schreibt eine Arbeitsmappe als Excel-Datei (.xlsx, Office Open XML) ohne Fremdbibliothek: Texte als
-/// Inline-Zeichenketten, Datum und Uhrzeit als Excel-Seriennummern mit passendem Format, Fettschrift, Farben,
-/// Spaltenbreiten nach Inhalt sowie stehende Kopfzeile mit Filter.
-///
-public static class XlsxDatei
-{
- /// Höchstlänge eines Blattnamens in Excel.
- public const int MaxBlattname = 31;
-
- private const string Haupt = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
- private const string Beziehung = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
- private const string Paket = "http://schemas.openxmlformats.org/package/2006/relationships";
- private const string Inhaltstypen = "http://schemas.openxmlformats.org/package/2006/content-types";
- private const string Typ = "application/vnd.openxmlformats-officedocument.spreadsheetml.";
- private const int FormatDatum = 164;
- private const int FormatUhrzeit = 165;
- private const int FormatGanzzahl = 3;
-
- private static readonly char[] Verboten = ['[', ']', ':', '*', '?', '/', '\\'];
-
- /// Speichert die Arbeitsmappe.
- /// Pfad der Datei.
- /// Die Arbeitsmappe.
- public static void Speichern(string pfad, Arbeitsmappe mappe)
- {
- ArgumentException.ThrowIfNullOrEmpty(pfad);
- using FileStream datei = File.Create(pfad);
- Schreiben(datei, mappe);
- }
-
- /// Schreibt die Arbeitsmappe in einen Datenstrom.
- /// Der Datenstrom; er bleibt offen.
- /// Die Arbeitsmappe.
- public static void Schreiben(Stream ziel, Arbeitsmappe mappe)
- {
- ArgumentNullException.ThrowIfNull(ziel);
- ArgumentNullException.ThrowIfNull(mappe);
- IReadOnlyList namen = Blattnamen(mappe.Blaetter.Select(b => b.Name).ToList());
- var stile = new Stilliste();
- using var zip = new ZipArchive(ziel, ZipArchiveMode.Create, leaveOpen: true);
- for (int i = 0; i < mappe.Blaetter.Count; i++)
- {
- Arbeitsblatt blatt = mappe.Blaetter[i];
- Eintrag(zip, $"xl/worksheets/sheet{i + 1}.xml", w => Blatt(w, blatt, stile, ersteBlatt: i == 0));
- }
-
- Eintrag(zip, "xl/styles.xml", stile.Ausgeben);
- Eintrag(zip, "xl/workbook.xml", w => Mappe(w, namen));
- Eintrag(zip, "xl/_rels/workbook.xml.rels", w => MappenBeziehungen(w, namen.Count));
- Eintrag(zip, "_rels/.rels", w =>
- {
- w.WriteStartElement("Relationships", Paket);
- Beziehungseintrag(w, "rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "xl/workbook.xml");
- w.WriteEndElement();
- });
- Eintrag(zip, "[Content_Types].xml", w => Typen(w, namen.Count));
- }
-
- /// Macht Blattnamen für Excel zulässig: verbotene Zeichen ersetzen, auf 31 Zeichen kürzen, eindeutig machen.
- /// Die gewünschten Namen.
- /// Die zulässigen Namen in derselben Reihenfolge.
- public static IReadOnlyList Blattnamen(IReadOnlyList namen)
- {
- ArgumentNullException.ThrowIfNull(namen);
- var vergeben = new HashSet(StringComparer.OrdinalIgnoreCase);
- var ergebnis = new List();
- for (int i = 0; i < namen.Count; i++)
- {
- string name = string.Concat(namen[i].Select(z => Verboten.Contains(z) ? '_' : z)).Trim().Trim('\'');
- if (name.Length == 0)
- {
- name = "Blatt " + (i + 1).ToString(CultureInfo.InvariantCulture);
- }
-
- name = Kuerzen(name, MaxBlattname);
- string kandidat = name;
- int n = 1;
- while (!vergeben.Add(kandidat))
- {
- n++;
- string zusatz = " (" + n.ToString(CultureInfo.InvariantCulture) + ")";
- kandidat = Kuerzen(name, MaxBlattname - zusatz.Length) + zusatz;
- }
-
- ergebnis.Add(kandidat);
- }
-
- return ergebnis;
- }
-
- /// Spaltenbezeichnung wie in Excel.
- /// Spalte ab 0.
- /// z. B. A, Z, AA.
- public static string Spaltenname(int spalte)
- {
- ArgumentOutOfRangeException.ThrowIfNegative(spalte);
- var name = new StringBuilder();
- for (int n = spalte + 1; n > 0; n = (n - 1) / 26)
- {
- name.Insert(0, (char)('A' + ((n - 1) % 26)));
- }
-
- return name.ToString();
- }
-
- private static string Kuerzen(string text, int laenge) => text.Length <= laenge ? text : text[..laenge].TrimEnd();
-
- private static void Eintrag(ZipArchive zip, string name, Action schreiben)
- {
- ZipArchiveEntry eintrag = zip.CreateEntry(name, CompressionLevel.Optimal);
- using Stream strom = eintrag.Open();
- using var w = XmlWriter.Create(strom, new XmlWriterSettings { Encoding = new UTF8Encoding(false) });
- w.WriteStartDocument(true);
- schreiben(w);
- w.WriteEndDocument();
- }
-
- private static void Beziehungseintrag(XmlWriter w, string id, string typ, string ziel)
- {
- w.WriteStartElement("Relationship", Paket);
- w.WriteAttributeString("Id", id);
- w.WriteAttributeString("Type", typ);
- w.WriteAttributeString("Target", ziel);
- w.WriteEndElement();
- }
-
- private static void Typen(XmlWriter w, int blaetter)
- {
- w.WriteStartElement("Types", Inhaltstypen);
- Standardtyp(w, "rels", "application/vnd.openxmlformats-package.relationships+xml");
- Standardtyp(w, "xml", "application/xml");
- Teiltyp(w, "/xl/workbook.xml", Typ + "sheet.main+xml");
- Teiltyp(w, "/xl/styles.xml", Typ + "styles+xml");
- for (int i = 1; i <= blaetter; i++)
- {
- Teiltyp(w, $"/xl/worksheets/sheet{i}.xml", Typ + "worksheet+xml");
- }
-
- w.WriteEndElement();
- }
-
- private static void Standardtyp(XmlWriter w, string endung, string typ)
- {
- w.WriteStartElement("Default", Inhaltstypen);
- w.WriteAttributeString("Extension", endung);
- w.WriteAttributeString("ContentType", typ);
- w.WriteEndElement();
- }
-
- private static void Teiltyp(XmlWriter w, string teil, string typ)
- {
- w.WriteStartElement("Override", Inhaltstypen);
- w.WriteAttributeString("PartName", teil);
- w.WriteAttributeString("ContentType", typ);
- w.WriteEndElement();
- }
-
- private static void Mappe(XmlWriter w, IReadOnlyList namen)
- {
- w.WriteStartElement("workbook", Haupt);
- w.WriteAttributeString("xmlns", "r", null, Beziehung);
- w.WriteStartElement("sheets", Haupt);
- for (int i = 0; i < namen.Count; i++)
- {
- w.WriteStartElement("sheet", Haupt);
- w.WriteAttributeString("name", namen[i]);
- w.WriteAttributeString("sheetId", (i + 1).ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("id", Beziehung, "rId" + (i + 1).ToString(CultureInfo.InvariantCulture));
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- w.WriteEndElement();
- }
-
- private static void MappenBeziehungen(XmlWriter w, int blaetter)
- {
- w.WriteStartElement("Relationships", Paket);
- for (int i = 1; i <= blaetter; i++)
- {
- string nummer = i.ToString(CultureInfo.InvariantCulture);
- Beziehungseintrag(w, "rId" + nummer, Beziehung + "/worksheet", "worksheets/sheet" + nummer + ".xml");
- }
-
- Beziehungseintrag(w, "rId" + (blaetter + 1).ToString(CultureInfo.InvariantCulture), Beziehung + "/styles", "styles.xml");
- w.WriteEndElement();
- }
-
- private static void Blatt(XmlWriter w, Arbeitsblatt blatt, Stilliste stile, bool ersteBlatt)
- {
- int spalten = blatt.Zeilen.Select(z => z.Count).DefaultIfEmpty(0).Max();
- w.WriteStartElement("worksheet", Haupt);
- w.WriteAttributeString("xmlns", "r", null, Beziehung);
- w.WriteStartElement("sheetViews", Haupt);
- w.WriteStartElement("sheetView", Haupt);
- if (ersteBlatt)
- {
- w.WriteAttributeString("tabSelected", "1");
- }
-
- w.WriteAttributeString("workbookViewId", "0");
- if (blatt.Kopfzeile is int kopf)
- {
- string zeilen = (kopf + 1).ToString(CultureInfo.InvariantCulture);
- w.WriteStartElement("pane", Haupt);
- w.WriteAttributeString("ySplit", zeilen);
- w.WriteAttributeString("topLeftCell", "A" + (kopf + 2).ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("activePane", "bottomLeft");
- w.WriteAttributeString("state", "frozen");
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- w.WriteEndElement();
- Spaltenbreiten(w, blatt, spalten);
- w.WriteStartElement("sheetData", Haupt);
- for (int z = 0; z < blatt.Zeilen.Count; z++)
- {
- Zeile(w, blatt.Zeilen[z], z, stile);
- }
-
- w.WriteEndElement();
- if (blatt.Kopfzeile is int k && spalten > 0)
- {
- w.WriteStartElement("autoFilter", Haupt);
- w.WriteAttributeString("ref", $"A{k + 1}:{Spaltenname(spalten - 1)}{Math.Max(blatt.Zeilen.Count, k + 1)}");
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- }
-
- private static void Spaltenbreiten(XmlWriter w, Arbeitsblatt blatt, int spalten)
- {
- if (spalten == 0)
- {
- return;
- }
-
- w.WriteStartElement("cols", Haupt);
- for (int s = 0; s < spalten; s++)
- {
- int spalte = s;
- int zeichen = blatt.Zeilen.Where(z => spalte < z.Count).Select(z => Anzeigelaenge(z[spalte])).DefaultIfEmpty(0).Max();
- double breite = Math.Clamp((zeichen * 1.1) + 2, 6, 60);
- string nummer = (s + 1).ToString(CultureInfo.InvariantCulture);
- w.WriteStartElement("col", Haupt);
- w.WriteAttributeString("min", nummer);
- w.WriteAttributeString("max", nummer);
- w.WriteAttributeString("width", breite.ToString("0.##", CultureInfo.InvariantCulture));
- w.WriteAttributeString("customWidth", "1");
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- }
-
- private static int Anzeigelaenge(Blattzelle zelle) => zelle.Wert switch
- {
- null => 0,
- string text => text.Split('\n').Max(t => t.Length) + (zelle.Fett ? 1 : 0),
- DateTime => 10,
- TimeSpan => 5,
- IFormattable zahl => zahl.ToString("N0", CultureInfo.GetCultureInfo("de-DE")).Length,
- _ => 10,
- };
-
- private static void Zeile(XmlWriter w, IReadOnlyList zellen, int zeile, Stilliste stile)
- {
- string nummer = (zeile + 1).ToString(CultureInfo.InvariantCulture);
- w.WriteStartElement("row", Haupt);
- w.WriteAttributeString("r", nummer);
- for (int s = 0; s < zellen.Count; s++)
- {
- Blattzelle zelle = zellen[s];
- if (zelle.Wert is null && zelle.Hintergrund is null)
- {
- // Leere Zellen nur schreiben, wenn sie eingefärbt sind.
- continue;
- }
-
- int stil = stile.Index(zelle);
-
- w.WriteStartElement("c", Haupt);
- w.WriteAttributeString("r", Spaltenname(s) + nummer);
- if (stil != 0)
- {
- w.WriteAttributeString("s", stil.ToString(CultureInfo.InvariantCulture));
- }
-
- Wert(w, zelle.Wert);
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- }
-
- private static void Wert(XmlWriter w, object? wert)
- {
- string? zahl = wert switch
- {
- DateTime datum => datum.ToOADate().ToString("R", CultureInfo.InvariantCulture),
- TimeSpan zeit => zeit.TotalDays.ToString("R", CultureInfo.InvariantCulture),
- int ganz => ganz.ToString(CultureInfo.InvariantCulture),
- double komma => komma.ToString("R", CultureInfo.InvariantCulture),
- _ => null,
- };
- if (zahl is not null)
- {
- w.WriteElementString("v", Haupt, zahl);
- return;
- }
-
- if (wert is null)
- {
- return;
- }
-
- string text = Convert.ToString(wert, CultureInfo.InvariantCulture) ?? string.Empty;
- w.WriteAttributeString("t", "inlineStr");
- w.WriteStartElement("is", Haupt);
- w.WriteStartElement("t", Haupt);
- if (text.Length > 0 && (char.IsWhiteSpace(text[0]) || char.IsWhiteSpace(text[^1]) || text.Contains('\n', StringComparison.Ordinal)))
- {
- w.WriteAttributeString("xml", "space", null, "preserve");
- }
-
- w.WriteString(text);
- w.WriteEndElement();
- w.WriteEndElement();
- }
-
- /// Sammelt die Zellformate (Zahlenformat, Schrift, Füllung) und schreibt sie als styles.xml.
- private sealed class Stilliste
- {
- private readonly List<(bool Fett, int? Farbe)> schriften = [(false, null)];
- private readonly List fuellungen = [null, null];
- private readonly List<(int Format, int Schrift, int Fuellung)> stile = [(0, 0, 0)];
-
- public int Index(Blattzelle zelle)
- {
- int format = zelle.Format switch
- {
- Zellformat.Datum => FormatDatum,
- Zellformat.Uhrzeit => FormatUhrzeit,
- Zellformat.Ganzzahl => FormatGanzzahl,
- _ => 0,
- };
- int schrift = Hinzufuegen(schriften, (zelle.Fett, zelle.Schriftfarbe));
- int fuellung = zelle.Hintergrund is null ? 0 : Hinzufuegen(fuellungen, zelle.Hintergrund, ab: 2);
- return Hinzufuegen(stile, (format, schrift, fuellung));
- }
-
- public void Ausgeben(XmlWriter w)
- {
- w.WriteStartElement("styleSheet", Haupt);
- w.WriteStartElement("numFmts", Haupt);
- w.WriteAttributeString("count", "2");
- Zahlenformat(w, FormatDatum, "dd.mm.yyyy");
- Zahlenformat(w, FormatUhrzeit, "hh:mm");
- w.WriteEndElement();
-
- Liste(w, "fonts", schriften, s =>
- {
- w.WriteStartElement("font", Haupt);
- if (s.Fett)
- {
- w.WriteStartElement("b", Haupt);
- w.WriteEndElement();
- }
-
- Wertelement(w, "sz", "11");
- if (s.Farbe is int farbe)
- {
- w.WriteStartElement("color", Haupt);
- w.WriteAttributeString("rgb", Argb(farbe));
- w.WriteEndElement();
- }
-
- Wertelement(w, "name", "Calibri");
- w.WriteEndElement();
- });
-
- w.WriteStartElement("fills", Haupt);
- w.WriteAttributeString("count", fuellungen.Count.ToString(CultureInfo.InvariantCulture));
- for (int i = 0; i < fuellungen.Count; i++)
- {
- Fuellung(w, i, fuellungen[i]);
- }
-
- w.WriteEndElement();
-
- w.WriteStartElement("borders", Haupt);
- w.WriteAttributeString("count", "1");
- w.WriteStartElement("border", Haupt);
- foreach (string seite in new[] { "left", "right", "top", "bottom", "diagonal" })
- {
- w.WriteStartElement(seite, Haupt);
- w.WriteEndElement();
- }
-
- w.WriteEndElement();
- w.WriteEndElement();
-
- w.WriteStartElement("cellStyleXfs", Haupt);
- w.WriteAttributeString("count", "1");
- Formatsatz(w, (0, 0, 0), mitVerweis: false);
- w.WriteEndElement();
-
- Liste(w, "cellXfs", stile, s => Formatsatz(w, s, mitVerweis: true));
-
- w.WriteStartElement("cellStyles", Haupt);
- w.WriteAttributeString("count", "1");
- w.WriteStartElement("cellStyle", Haupt);
- w.WriteAttributeString("name", "Standard");
- w.WriteAttributeString("xfId", "0");
- w.WriteAttributeString("builtinId", "0");
- w.WriteEndElement();
- w.WriteEndElement();
- w.WriteEndElement();
- }
-
- private static int Hinzufuegen(List liste, T wert, int ab = 0)
- {
- int index = liste.FindIndex(ab, e => EqualityComparer.Default.Equals(e, wert));
- if (index >= 0)
- {
- return index;
- }
-
- liste.Add(wert);
- return liste.Count - 1;
- }
-
- private static string Argb(int farbe) => "FF" + (farbe & 0xFFFFFF).ToString("X6", CultureInfo.InvariantCulture);
-
- private static void Wertelement(XmlWriter w, string name, string wert)
- {
- w.WriteStartElement(name, Haupt);
- w.WriteAttributeString("val", wert);
- w.WriteEndElement();
- }
-
- private static void Zahlenformat(XmlWriter w, int id, string code)
- {
- w.WriteStartElement("numFmt", Haupt);
- w.WriteAttributeString("numFmtId", id.ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("formatCode", code);
- w.WriteEndElement();
- }
-
- private static void Fuellung(XmlWriter w, int index, int? farbe)
- {
- w.WriteStartElement("fill", Haupt);
- w.WriteStartElement("patternFill", Haupt);
- if (index == 1)
- {
- w.WriteAttributeString("patternType", "gray125");
- }
- else if (farbe is int f)
- {
- w.WriteAttributeString("patternType", "solid");
- w.WriteStartElement("fgColor", Haupt);
- w.WriteAttributeString("rgb", Argb(f));
- w.WriteEndElement();
- w.WriteStartElement("bgColor", Haupt);
- w.WriteAttributeString("indexed", "64");
- w.WriteEndElement();
- }
- else
- {
- w.WriteAttributeString("patternType", "none");
- }
-
- w.WriteEndElement();
- w.WriteEndElement();
- }
-
- private static void Formatsatz(XmlWriter w, (int Format, int Schrift, int Fuellung) stil, bool mitVerweis)
- {
- w.WriteStartElement("xf", Haupt);
- w.WriteAttributeString("numFmtId", stil.Format.ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("fontId", stil.Schrift.ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("fillId", stil.Fuellung.ToString(CultureInfo.InvariantCulture));
- w.WriteAttributeString("borderId", "0");
- if (mitVerweis)
- {
- w.WriteAttributeString("xfId", "0");
- if (stil.Format != 0)
- {
- w.WriteAttributeString("applyNumberFormat", "1");
- }
-
- if (stil.Schrift != 0)
- {
- w.WriteAttributeString("applyFont", "1");
- }
-
- if (stil.Fuellung != 0)
- {
- w.WriteAttributeString("applyFill", "1");
- }
- }
-
- w.WriteEndElement();
- }
-
- private static void Liste(XmlWriter w, string name, List eintraege, Action schreiben)
- {
- w.WriteStartElement(name, Haupt);
- w.WriteAttributeString("count", eintraege.Count.ToString(CultureInfo.InvariantCulture));
- foreach (T eintrag in eintraege)
- {
- schreiben(eintrag);
- }
-
- w.WriteEndElement();
- }
- }
-}
diff --git a/src/AndiGenerator.Persistence/Tabellen/Zellformat.cs b/src/AndiGenerator.Persistence/Tabellen/Zellformat.cs
deleted file mode 100644
index e2d81e7..0000000
--- a/src/AndiGenerator.Persistence/Tabellen/Zellformat.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-namespace AndiGenerator.Persistence.Tabellen;
-
-/// Zahlenformat einer Zelle.
-public enum Zellformat
-{
- /// Text wie er ist, Zahlen ohne besonderes Format.
- Standard,
-
- /// Datum TT.MM.JJJJ.
- Datum,
-
- /// Uhrzeit hh:mm.
- Uhrzeit,
-
- /// Ganze Zahl mit Tausenderpunkt.
- Ganzzahl,
-}
diff --git a/src/AndiGenerator.Presentation/AnsichtViewModel.cs b/src/AndiGenerator.Presentation/AnsichtViewModel.cs
index fa51e83..1ed5893 100644
--- a/src/AndiGenerator.Presentation/AnsichtViewModel.cs
+++ b/src/AndiGenerator.Presentation/AnsichtViewModel.cs
@@ -2,7 +2,6 @@
// SPDX-FileCopyrightText: 2026 Peter Buchmann
// SPDX-License-Identifier: GPL-3.0-only
-using CommunityToolkit.Mvvm.Input;
using Dock.Model.Mvvm.Controls;
namespace AndiGenerator.Presentation;
@@ -18,7 +17,6 @@ public abstract class AnsichtViewModel : Document, IZoombar
private IReadOnlyList quellen;
private PlanQuelle? quelle;
private string hinweis = string.Empty;
- private bool hatPlan;
/// Initialisiert die Ansicht.
/// Hauptfenster, das Pläne und Quellen liefert.
@@ -35,8 +33,6 @@ protected AnsichtViewModel(HauptfensterViewModel hauptfenster, string art, strin
quellen = hauptfenster.Planquellen;
quelle = PlanQuelle.Laufend;
Title = TitelBilden();
- EntfernenCommand = new AsyncRelayCommand(EntfernenAsync, () => Quelle?.Art == PlanQuellenArt.GemerkterPlan);
- ExcelCommand = new AsyncRelayCommand(ExcelAsync, () => hatPlan);
}
/// Holt die auswählbaren Planquellen.
@@ -55,7 +51,6 @@ public PlanQuelle? Quelle
if (SetProperty(ref quelle, value))
{
Title = TitelBilden();
- EntfernenCommand.NotifyCanExecuteChanged();
Aktualisieren();
}
}
@@ -80,12 +75,6 @@ protected set
///
public Zoom Zoom { get; } = new();
- /// Holt den Befehl, der den gewählten gemerkten Plan löscht.
- public IAsyncRelayCommand EntfernenCommand { get; }
-
- /// Holt den Befehl „Nach Excel…“: den angezeigten Plan als Excel-Arbeitsmappe speichern.
- public IAsyncRelayCommand ExcelCommand { get; }
-
/// Holt die Quelle als Art (für die Aktualisierung bei Verbesserungen).
internal PlanQuellenArt? QuellenArt => Quelle?.Art;
@@ -97,9 +86,8 @@ internal void Aktualisieren()
{
Planstand? stand = Quelle is null ? null : hauptfenster.PlanFuer(Quelle);
Hinweis = stand is null ? HinweisOhnePlan() : string.Empty;
- hatPlan = stand is not null;
- ExcelCommand.NotifyCanExecuteChanged();
Anzeigen(stand);
+ hauptfenster.ExportAktualisieren();
}
/// Übernimmt neue Planquellen und behält die gewählte bei, sofern es sie noch gibt.
@@ -134,8 +122,4 @@ internal void QuellenUebernehmen(IReadOnlyList neu)
};
private string TitelBilden() => Quelle is null ? art : $"{art} – {Quelle.Name}";
-
- private Task ExcelAsync() => Quelle is null ? Task.CompletedTask : hauptfenster.ExcelExportierenAsync(Quelle);
-
- private Task EntfernenAsync() => Quelle is null ? Task.CompletedTask : hauptfenster.GemerktenPlanEntfernenAsync(Quelle);
}
diff --git a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs
index 615ac5d..2d7cdc7 100644
--- a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs
+++ b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs
@@ -18,7 +18,7 @@ public sealed class EinrichtungViewModel : ObservableObject
{
private readonly Func> uebernehmen;
private readonly Func seiteErzeugen;
- private readonly Func abschliessen;
+ private readonly Func abschliessen;
private Einrichtungsschritt aktiv;
private string meldung = string.Empty;
@@ -27,13 +27,13 @@ public sealed class EinrichtungViewModel : ObservableObject
/// Die Punkte in ihrer Reihenfolge (mindestens einer).
/// Übernimmt einen Punkt; false, wenn er nicht übernommen werden konnte.
/// Erzeugt die eingebettete Seite eines Datenpunkts mit dem aktuellen Stand der Daten.
- /// Schließt die Seite; der Parameter sagt, ob danach generiert werden soll.
+ /// Schließt die Seite; generiert wird danach erst mit „Generierung starten“.
public EinrichtungViewModel(
string titel,
IReadOnlyList schritte,
Func> uebernehmen,
Func seiteErzeugen,
- Func abschliessen)
+ Func abschliessen)
{
ArgumentNullException.ThrowIfNull(schritte);
ArgumentOutOfRangeException.ThrowIfZero(schritte.Count);
@@ -54,8 +54,7 @@ public EinrichtungViewModel(
Zeigen(aktiv);
UebernehmenCommand = new AsyncRelayCommand(UebernehmenAsync);
UeberspringenCommand = new RelayCommand(Ueberspringen);
- AbschliessenCommand = new AsyncRelayCommand(() => this.abschliessen(false));
- AbschliessenUndGenerierenCommand = new AsyncRelayCommand(() => this.abschliessen(true));
+ AbschliessenCommand = new AsyncRelayCommand(AbschliessenAsync);
}
/// Holt die Überschrift.
@@ -115,12 +114,12 @@ private set
/// Holt den Befehl „Überspringen“.
public IRelayCommand UeberspringenCommand { get; }
- /// Holt den Befehl „Abschließen“ (offene Punkte bleiben unverändert).
+ ///
+ /// Holt den Befehl „Abschließen“: Bei offenen Entscheidungen gilt die angeklickte Wahl, alle übrigen offenen Punkte
+ /// bleiben unverändert.
+ ///
public IAsyncRelayCommand AbschliessenCommand { get; }
- /// Holt den Befehl „Abschließen und generieren“.
- public IAsyncRelayCommand AbschliessenUndGenerierenCommand { get; }
-
private int Bearbeitet => Schritte.Count(s => s.Zustand != Schrittzustand.Offen);
private void Zeigen(Einrichtungsschritt schritt)
@@ -144,6 +143,23 @@ private async Task UebernehmenAsync()
Abhaken(schritt, Schrittzustand.Erledigt);
}
+ private async Task AbschliessenAsync()
+ {
+ foreach (Einrichtungsschritt schritt in Schritte.Where(s => s.IstEntscheidung && s.IstGewaehlt && s.Zustand == Schrittzustand.Offen).ToList())
+ {
+ if (!await uebernehmen(schritt))
+ {
+ Aktiv = schritt;
+ Meldung = "Der Punkt konnte nicht übernommen werden.";
+ return;
+ }
+
+ Abhaken(schritt, Schrittzustand.Erledigt);
+ }
+
+ await abschliessen();
+ }
+
private void Ueberspringen() => Abhaken(Aktiv, Schrittzustand.Uebersprungen);
private void Abhaken(Einrichtungsschritt schritt, Schrittzustand zustand)
diff --git a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs
index 1e32a90..b3e1ed2 100644
--- a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs
+++ b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs
@@ -78,9 +78,21 @@ public Einrichtungsschritt(int nummer, Erststarthinweis hinweis)
public int Auswahl
{
get => auswahl;
- set => SetProperty(ref auswahl, value < 0 ? 0 : value);
+ set
+ {
+ if (SetProperty(ref auswahl, value < 0 ? 0 : value))
+ {
+ IstGewaehlt = true;
+ }
+ }
}
+ ///
+ /// Holt einen Wert, der angibt, ob der Anwender bei dieser Entscheidung eine andere Wahl angeklickt hat; sie gilt dann
+ /// auch beim Abschließen, ohne dass „Auswahl übernehmen“ gedrückt wurde.
+ ///
+ public bool IstGewaehlt { get; private set; }
+
/// Holt die eingebettete Seite der Spielplandaten (nur Datenpunkte, solange der Schritt gewählt ist).
public DatenDialogViewModel? Seite
{
diff --git a/src/AndiGenerator.Presentation/Generierungsanzeige.cs b/src/AndiGenerator.Presentation/Generierungsanzeige.cs
index c65d0f5..1b0e252 100644
--- a/src/AndiGenerator.Presentation/Generierungsanzeige.cs
+++ b/src/AndiGenerator.Presentation/Generierungsanzeige.cs
@@ -4,6 +4,7 @@
using System.Globalization;
using AndiGenerator.Application;
+using AndiGenerator.Engine.Inseln;
using AndiGenerator.Engine.Referenz;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -18,6 +19,8 @@ public sealed class Generierungsanzeige : ObservableObject
private const string SeitAnpassung = "seit der letzten Kostenanpassung";
+ private const string TitelKosten = "Gesamtkosten (bester Plan)";
+
/// Ab diesen Kosten hat ein Plan mindestens einen harten Fehler (Original: 10 × cMaxKostenNoHardError).
private const double HarteFehler = 10.0 * 1_000_000_000.0;
@@ -34,6 +37,19 @@ public sealed class Generierungsanzeige : ObservableObject
private string pflicht = Leer;
private string pflichtHinweis = string.Empty;
private bool pflichtErfuellt = true;
+ private bool zeigtVerstoesse;
+ private string titel = TitelKosten;
+ private string verstossA = Leer;
+ private string verstossB = Leer;
+ private string verstossC = Leer;
+ private string basisA = string.Empty;
+ private string basisB = string.Empty;
+ private string basisC = string.Empty;
+ private string phase = string.Empty;
+ private Stufenwert? verstoesse;
+
+ /// Wird ausgelöst, wenn zwischen Kosten und Verstößen umgeschaltet wird (Ziel des Klicks auf die Kachel).
+ public event EventHandler? ZeigtVerstoesseGeaendert;
/// Holt die Kosten des besten Plans.
public string Kosten
@@ -98,6 +114,78 @@ public bool PflichtErfuellt
private set => SetProperty(ref pflichtErfuellt, value);
}
+ /// Holt die Überschrift der Kachel: Verfahren und was gezeigt wird.
+ public string Titel
+ {
+ get => titel;
+ private set => SetProperty(ref titel, value);
+ }
+
+ /// Holt einen Wert, der angibt, ob statt der Kosten die Verstöße A, B und C gezeigt werden (Automodus).
+ public bool ZeigtVerstoesse
+ {
+ get => zeigtVerstoesse;
+ private set
+ {
+ if (SetProperty(ref zeigtVerstoesse, value))
+ {
+ ZeigtVerstoesseGeaendert?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ }
+
+ /// Holt die Zahl der Verstöße der Stufe A des besten Plans.
+ public string VerstossA
+ {
+ get => verstossA;
+ private set => SetProperty(ref verstossA, value);
+ }
+
+ /// Holt die Zahl der Verstöße der Stufe B des besten Plans.
+ public string VerstossB
+ {
+ get => verstossB;
+ private set => SetProperty(ref verstossB, value);
+ }
+
+ /// Holt die Zahl der Verstöße der Stufe C des besten Plans.
+ public string VerstossC
+ {
+ get => verstossC;
+ private set => SetProperty(ref verstossC, value);
+ }
+
+ /// Holt die Verstöße der Stufe A am Ende der Basisoptimierung mit Pfeil, z. B. 14 →; leer davor.
+ public string BasisA
+ {
+ get => basisA;
+ private set => SetProperty(ref basisA, value);
+ }
+
+ /// Holt die Verstöße der Stufe B am Ende der Basisoptimierung mit Pfeil; leer davor.
+ public string BasisB
+ {
+ get => basisB;
+ private set => SetProperty(ref basisB, value);
+ }
+
+ /// Holt die Verstöße der Stufe C am Ende der Basisoptimierung mit Pfeil; leer davor.
+ public string BasisC
+ {
+ get => basisC;
+ private set => SetProperty(ref basisC, value);
+ }
+
+ ///
+ /// Holt die Kriterien, an denen der Automodus gerade arbeitet, alle mit Namen, z. B. Hallenbelegung, Pflichtspieltage
+ /// oder Spielverteilung (unten in der Kachel); leer in der Basisoptimierung und bei der Kostenoptimierung.
+ ///
+ public string Phase
+ {
+ get => phase;
+ private set => SetProperty(ref phase, value);
+ }
+
///
/// Merkt sich, dass sich die Kostenfunktion geändert hat (Gewichte, Optionen oder Spielplandaten): Die alten Kosten
/// sind nicht mehr vergleichbar, Bezug der prozentualen Verbesserung ist ab jetzt der erste gültige Plan danach.
@@ -115,6 +203,9 @@ public void Uebernehmen(Optimierungsstand stand)
ArgumentNullException.ThrowIfNull(stand);
Plaene = Kostenanzeige.Kurz(stand.Durchlaeufe);
Tempo = stand.PlaeneProSekunde.ToString("N0", Deutsch) + " Pläne/s";
+ ZeigtVerstoesse = stand.Lenkung is not null;
+ Titel = stand.Lenkung is { } lenkung ? "Automodus · " + lenkung.Bezeichnung : "Kostenoptimierung · Gesamtkosten";
+ LenkungZeigen(stand.Lenkung);
if (stand.Kosten < 0)
{
KostenHinweis = stand.Pausiert ? "angehalten" : "erster Plan wird gesucht …";
@@ -131,7 +222,13 @@ public void Uebernehmen(Optimierungsstand stand)
Kosten = Kostenanzeige.Kurz(stand.Kosten);
Verbesserungen = stand.Verbesserungen.ToString(Deutsch);
LetzteVerbesserung = string.Create(Deutsch, $"letzte vor {stand.SeitLetzterVerbesserung:hh\\:mm\\:ss}");
- KostenHinweis = stand.Pausiert ? "angehalten" : Veraenderung(stand.Kosten);
+ string veraenderung = ZeigtVerstoesse ? VerstossVeraenderung() : Veraenderung(stand.Kosten);
+ KostenHinweis = (stand.Pausiert, veraenderung.Length) switch
+ {
+ (false, _) => veraenderung,
+ (true, 0) => "angehalten",
+ _ => "angehalten · " + veraenderung,
+ };
}
/// Setzt die Anzeige für eine neue Generierung oder Staffel zurück.
@@ -149,20 +246,68 @@ internal void Zuruecksetzen(string hinweis)
Pflicht = Leer;
PflichtHinweis = string.Empty;
PflichtErfuellt = true;
+ ZeigtVerstoesse = false;
+ Titel = TitelKosten;
+ verstoesse = null;
+ VerstossA = Leer;
+ VerstossB = Leer;
+ VerstossC = Leer;
+ LenkungZeigen(null);
+ }
+
+ /// Zeigt vor dem Start einen gewählten Ausgangsplan: seine Kosten und Pflichtregeln, noch ohne Generierung.
+ /// Bewertung des Plans.
+ /// Einteilung der Kriterien.
+ /// Name des Plans für den Hinweis.
+ internal void PlanZeigen(Planbewertung bewertung, Stufeneinteilung einteilung, string name)
+ {
+ Zuruecksetzen("Ausgangsplan: " + name);
+ Kosten = Kostenanzeige.Kurz(bewertung.Gesamtkosten);
+ Uebernehmen(bewertung, einteilung);
}
/// Übernimmt die Pflichtregeln des besten Plans.
/// Die Bewertung des besten Plans.
- internal void Uebernehmen(Planbewertung bewertung)
+ /// Einteilung der Kriterien durch den Staffelleiter.
+ internal void Uebernehmen(Planbewertung bewertung, Stufeneinteilung einteilung)
{
- IReadOnlyList kriterien = Planqualitaet.Kriterien(bewertung);
+ IReadOnlyList kriterien = Planqualitaet.Kriterien(bewertung, einteilung);
int verletzt = kriterien.Count(k => k.IstPflicht && !k.Erfuellt);
int weitere = kriterien.Count(k => !k.IstPflicht && !k.Erfuellt);
PflichtErfuellt = verletzt == 0;
Pflicht = verletzt == 0 ? "alle erfüllt" : string.Create(Deutsch, $"{verletzt} verletzt");
PflichtHinweis = weitere == 1 ? "1 weiteres Kriterium mit Verstößen" : string.Create(Deutsch, $"{weitere} weitere Kriterien mit Verstößen");
+
+ // Verstöße nach Stufen (wie im Automodus); Vergleich mit dem Stand nach der Basisoptimierung (Lenkungsstand).
+ Stufenwert wert = einteilung.Zaehlen(bewertung);
+ verstoesse = wert;
+
+ VerstossA = wert.A.ToString(Deutsch);
+ VerstossB = wert.B.ToString(Deutsch);
+ VerstossC = wert.CAnzahl.ToString(Deutsch);
}
+ private static string Basiswert(int? wert) => wert is int w ? string.Create(Deutsch, $"{w} →") : string.Empty;
+
+ /// Automodus: Verstöße am Ende der Basisoptimierung und die Kriterien, an denen gerade gearbeitet wird.
+ private void LenkungZeigen(Lenkungsstand? lenkung)
+ {
+ Stufenwert? basis = lenkung?.Basis;
+ BasisA = Basiswert(basis?.A);
+ BasisB = Basiswert(basis?.B);
+ BasisC = Basiswert(basis?.CAnzahl);
+ IReadOnlyList kriterien = lenkung?.Kriterien ?? [];
+ Phase = string.Join(", ", kriterien.Select(Planqualitaet.Name));
+ }
+
+ /// Untere Zeile im Automodus: nur Hinweise auf den Zustand (Bewertung ausstehend, harte Fehler), sonst leer.
+ private string VerstossVeraenderung() => verstoesse switch
+ {
+ null => "erster Plan wird bewertet …",
+ { Harte: > 0 } jetzt => string.Create(Deutsch, $"Plan hat noch {jetzt.Harte} harte Fehler"),
+ _ => string.Empty,
+ };
+
private string Veraenderung(double jetzt)
{
if (jetzt >= HarteFehler)
diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs
index bb30881..49cd419 100644
--- a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs
+++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs
@@ -7,11 +7,11 @@
using AndiGenerator.Application;
using AndiGenerator.Domain.Optionen;
using AndiGenerator.Domain.Stammdaten;
+using AndiGenerator.Engine.Inseln;
using AndiGenerator.Engine.Referenz;
using AndiGenerator.Persistence.ClickTt;
using AndiGenerator.Persistence.Gemeinsam;
using AndiGenerator.Persistence.Plandaten;
-using AndiGenerator.Persistence.Tabellen;
using AndiGenerator.Rendering;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -29,6 +29,8 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable
/// Dateiname der Anleitung neben dem Programm.
public const string Anleitungsdatei = "Anleitung.pdf";
+ private const string KeinExportplan = "Kein Plan zum Exportieren – erst eine Generierung starten oder einen Plan wählen.";
+
private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE");
private static readonly Dateifilter PlanDateien = new("click-TT- und Plandateien", ["*.xml"]);
@@ -47,6 +49,8 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable
private IReadOnlyList planquellen = [PlanQuelle.Laufend];
private IReadOnlyList startplaene = [Startplan.Leer];
private Startplan startplan = Startplan.Leer;
+ private string csvHinweis = KeinExportplan;
+ private bool automodus;
private OptionenAnsichtViewModel? optionenAnsicht;
private TerminwunschAnsichtViewModel? terminwunschAnsicht;
private NachbarterminAnsichtViewModel? nachbarterminAnsicht;
@@ -84,6 +88,7 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster
this.uebernahmeAus = uebernahmeAus;
dienst = new Optimierungsdienst();
dienst.Verbessert += BeiVerbesserung;
+ CsvExportierenCommand = new AsyncRelayCommand(CsvExportierenAsync, () => ExportQuelle() is not null);
// Zu Beginn nur die Kostenansicht; weitere Ansichten erst, wenn sie links angeklickt werden.
ansichten.Add(new KostenAnsichtViewModel(this, NaechsteId()));
@@ -94,6 +99,8 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster
{
AktiveAnsicht = art;
}
+
+ ExportAktualisieren();
};
fabrik.DockableClosed += (_, e) =>
{
@@ -122,6 +129,8 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster
NeuCommand = new AsyncRelayCommand(NeueDateiAsync);
SchliessenCommand = new AsyncRelayCommand(SchliessenAsync, () => IstGeoeffnet);
GenerierungCommand = new RelayCommand(GenerierungUmschalten, () => IstGeoeffnet);
+ KostenoptimierungCommand = new RelayCommand(() => VerfahrenWaehlen(false), () => IstGeoeffnet && !(Laeuft && !Automodus));
+ AutomodusCommand = new RelayCommand(() => VerfahrenWaehlen(true), () => IstGeoeffnet && !(Laeuft && Automodus));
ZuletztOeffnenCommand = new AsyncRelayCommand(ZuletztOeffnenAsync);
ZuletztEntfernenCommand = new RelayCommand(ZuletztEntfernen);
ZoomGroesserCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Groesser());
@@ -131,13 +140,19 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster
PauseCommand = new RelayCommand(PauseUmschalten, () => Laeuft);
StoppenCommand = new RelayCommand(Stoppen, () => Laeuft);
PlanMerkenCommand = new AsyncRelayCommand(PlanMerkenAsync, () => laufenderStand is not null);
- CsvExportierenCommand = new AsyncRelayCommand(CsvExportierenAsync, () => laufenderStand is not null);
+ PlanLoeschenCommand = new AsyncRelayCommand(
+ () => startplan.Quelle is PlanQuelle quelle ? GemerktenPlanEntfernenAsync(quelle) : Task.CompletedTask,
+ () => ZeigtPlanLoeschen);
DruckenCommand = new AsyncRelayCommand(DruckenAsync, () => ZeigtArbeitsbereich);
AnleitungCommand = new AsyncRelayCommand(AnleitungAsync);
UeberCommand = new AsyncRelayCommand(() => oberflaeche.UeberAnzeigenAsync(new UeberViewModel(oberflaeche, UeberViewModel.VersionVon(typeof(HauptfensterViewModel).Assembly), this.eigeneBasis)));
KostenansichtCommand = new RelayCommand(() => AnsichtZeigen(() => new KostenAnsichtViewModel(this, NaechsteId())), () => HatPlan && ZeigtArbeitsbereich);
TerminplanansichtCommand = new RelayCommand(() => AnsichtZeigen(() => new TerminplanAnsichtViewModel(this, NaechsteId())), () => HatPlan && ZeigtArbeitsbereich);
QualitaetsansichtCommand = new RelayCommand(() => AnsichtZeigen(() => new QualitaetAnsichtViewModel(this, NaechsteId())), () => HatPlan && ZeigtArbeitsbereich);
+ ErgebnisCommand = new RelayCommand(
+ () => (Anzeige.ZeigtVerstoesse ? QualitaetsansichtCommand : KostenansichtCommand).Execute(null),
+ () => HatPlan && ZeigtArbeitsbereich);
+ Anzeige.ZeigtVerstoesseGeaendert += (_, _) => OnPropertyChanged(nameof(ErgebnisHinweis));
MeldungsansichtCommand = new RelayCommand(() => AnsichtZeigen(() => new MeldungenAnsichtViewModel(this, NaechsteId())), () => HatPlan && ZeigtArbeitsbereich);
DiagrammansichtCommand = new RelayCommand(() => AnsichtZeigen(() => new DiagrammAnsichtViewModel(this, NaechsteId())), () => HatPlan && ZeigtArbeitsbereich);
EinstellungenCommand = new RelayCommand(EinstellungenOeffnen, () => ZeigtArbeitsbereich);
@@ -241,7 +256,7 @@ private set
/// Holt die Beschriftung des Knopfs, der die Generierung startet, anhält und fortsetzt.
public string GenerierungText => (Laeuft, Pausiert) switch
{
- (false, _) => "Generierung starten",
+ (false, _) => Automodus ? "Automodus starten" : "Kostenoptimierung starten",
(true, true) => "Fortsetzen",
_ => "Pausieren",
};
@@ -258,6 +273,12 @@ private set
///
public bool StartplanWaehlbar => IstGeoeffnet && !Laeuft;
+ ///
+ /// Holt einen Wert, der angibt, ob statt „Plan merken“ der Knopf „Plan löschen“ steht: vor dem Start, wenn unter
+ /// „Start mit“ ein gemerkter Plan gewählt ist.
+ ///
+ public bool ZeigtPlanLoeschen => StartplanWaehlbar && startplan.Quelle?.Art == PlanQuellenArt.GemerkterPlan;
+
/// Holt die Statuszeile (Pläne, Pläne/s, Kosten, Verbesserungen).
public string Statuszeile
{
@@ -346,8 +367,8 @@ public IReadOnlyList Planquellen
/// Holt den Befehl „Plan merken…“ (bester Plan der Generierung).
public IAsyncRelayCommand PlanMerkenCommand { get; }
- /// Holt den Befehl „CSV für click-TT exportieren…“ (bester Plan der Generierung).
- public IAsyncRelayCommand CsvExportierenCommand { get; }
+ /// Holt den Befehl „Plan löschen“: den unter „Start mit“ gewählten gemerkten Plan nach Rückfrage löschen.
+ public IAsyncRelayCommand PlanLoeschenCommand { get; }
/// Holt den Befehl „Drucken…“ (Original: Druckauswahl und Ausdruck; hier als PDF-Datei).
public IAsyncRelayCommand DruckenCommand { get; }
@@ -370,6 +391,14 @@ public IReadOnlyList Planquellen
/// Holt den Befehl „Neue Qualitätsansicht“.
public IRelayCommand QualitaetsansichtCommand { get; }
+ /// Holt den Befehl der Ergebniskachel: Kostenansicht, im Automodus die Qualitätsansicht.
+ public IRelayCommand ErgebnisCommand { get; }
+
+ /// Holt den Hinweis der Ergebniskachel.
+ public string ErgebnisHinweis => Anzeige.ZeigtVerstoesse
+ ? "Qualitätsansicht öffnen: welche Kriterien verletzt sind"
+ : "Kostenansicht öffnen: Kosten je Mannschaft und Kostenart";
+
/// Holt den Befehl „Neue Meldungsansicht“ (Meldungen je Mannschaft, im Original unter der Kostentabelle).
public IRelayCommand MeldungsansichtCommand { get; }
@@ -385,6 +414,19 @@ public IReadOnlyList Planquellen
/// Holt den Befehl „Spielplandaten…“ (Original Schaltfläche „Daten“).
public IAsyncRelayCommand DatenCommand { get; }
+ ///
+ /// Holt den Befehl „Für click-TT exportieren“: den aktuell angezeigten Plan (Plan der aktiven Ansicht, sonst den
+ /// Stand der Generierung bzw. den gewählten Ausgangsplan) als CSV speichern; deaktiviert, wenn es keinen gibt.
+ ///
+ public IAsyncRelayCommand CsvExportierenCommand { get; }
+
+ /// Holt den Hinweis zum click-TT-Export: welcher Plan exportiert würde.
+ public string CsvHinweis
+ {
+ get => csvHinweis;
+ private set => SetProperty(ref csvHinweis, value);
+ }
+
/// Holt die möglichen Ausgangspläne für „Generierung starten“.
public IReadOnlyList Startplaene
{
@@ -392,19 +434,67 @@ public IReadOnlyList Startplaene
private set => SetProperty(ref startplaene, value);
}
- /// Holt oder setzt den Ausgangsplan für „Generierung starten“ (Standard: leerer Plan wie im Original).
+ ///
+ /// Holt oder setzt den Ausgangsplan für „Generierung starten“ (Standard: leerer Plan wie im Original). Ein gewählter
+ /// vorhandener Plan wird sofort in allen Ansichten und den Kacheln gezeigt, ohne dass eine Optimierung startet.
+ ///
public Startplan? Startplan
{
get => startplan;
set
{
- if (value is not null)
+ if (value is not null && SetProperty(ref startplan, value))
+ {
+ PlanLoeschenAktualisieren();
+ if (value.Quelle is PlanQuelle quelle)
+ {
+ StartplanZeigen(quelle);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Holt einen Wert, der angibt, ob mit Automodus generiert wird bzw. beim nächsten Start werden soll: Die Suche lenkt sich
+ /// selbst über die Gewichte, erst Verstöße der Stufen A und B herausdrängen, dann C glätten (MIGRATIONSPLAN
+ /// Abschnitt 11). Sonst Kostenoptimierung wie im Original.
+ ///
+ public bool Automodus
+ {
+ get => automodus;
+ private set
+ {
+ if (SetProperty(ref automodus, value))
{
- SetProperty(ref startplan, value);
+ VerfahrenGeaendert();
+ foreach (QualitaetAnsichtViewModel qualitaet in ansichten.OfType())
+ {
+ qualitaet.VerfahrenGeaendert();
+ }
}
}
}
+ /// Holt den Eintrag „Kostenoptimierung“ im Auswahlmenü des Startknopfs.
+ public string KostenoptimierungText => Menueeintrag("Kostenoptimierung", !Automodus);
+
+ /// Holt den Eintrag „Automodus“ im Auswahlmenü des Startknopfs.
+ public string AutomodusText => Menueeintrag("Automodus", Automodus);
+
+ /// Holt den Hinweis zum Startknopf: welches Verfahren läuft bzw. startet.
+ public string VerfahrenHinweis => Automodus
+ ? "Automodus: Die Suche lenkt sich selbst über die Gewichte. Über den Pfeil rechts lässt sich zur Kostenoptimierung wechseln."
+ : "Kostenoptimierung wie im Original. Über den Pfeil rechts lässt sich zum Automodus wechseln.";
+
+ /// Holt den Befehl „Kostenoptimierung starten“ bzw. während der Generierung „zur Kostenoptimierung wechseln“.
+ public IRelayCommand KostenoptimierungCommand { get; }
+
+ /// Holt den Befehl „Automodus starten“ bzw. während der Generierung „zum Automodus wechseln“.
+ public IRelayCommand AutomodusCommand { get; }
+
+ /// Holt die Einteilung der Kriterien in die Stufen A, B und C der geöffneten Staffel (sonst den Standard).
+ internal Stufeneinteilung Einteilung => Stufeneinteilung.AusText(sitzung?.Staffel.Kriterienstufen);
+
/// Holt die Optionen der geöffneten Staffel (null, solange keine geöffnet ist).
internal Berechnungsoptionen? Optionen => sitzung?.Optionen;
@@ -460,6 +550,46 @@ public void StartzoomFestlegen(int prozent)
}
}
+ ///
+ /// Ändert die Einteilung der Kriterien (Qualitätsansicht) und speichert sie mit den Spielplandaten; eine laufende
+ /// Generierung übernimmt sie wie jede Datenänderung – der Automodus lenkt danach nach den neuen Stufen.
+ ///
+ /// Die Änderung an der bisherigen Einteilung.
+ /// Erledigt nach dem Speichern.
+ internal async Task EinteilungAendernAsync(Func aendern)
+ {
+ if (sitzung is not Plansitzung s)
+ {
+ return;
+ }
+
+ Stufeneinteilung bisher = Einteilung;
+ Stufeneinteilung neu = aendern(bisher);
+ if (neu.Text() == bisher.Text())
+ {
+ return;
+ }
+
+ try
+ {
+ s.KriterienstufenSpeichern(neu.Text());
+ }
+ catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
+ {
+ await oberflaeche.MeldenAsync("Qualität", "Die Einteilung konnte nicht gespeichert werden:" + Environment.NewLine + fehler.Message);
+ return;
+ }
+
+ // Die Kosten ändern sich nicht: Der laufende Automodus übernimmt die Einteilung ohne Neustart und macht beim
+ // wichtigsten geänderten Kriterium weiter.
+ dienst.EinteilungAendern(neu);
+ AlleAnsichtenAktualisieren();
+ if (laufenderStand is Planstand stand)
+ {
+ Anzeige.Uebernehmen(stand.Bewertung, Einteilung);
+ }
+ }
+
///
/// Ändert eine Gewichtung nach Rückfrage wie das Original nach einem Klick in der Kostenansicht: Optionen speichern,
/// laufende Generierung mit den neuen Gewichten fortsetzen, alle Ansichten neu bewerten.
@@ -520,6 +650,51 @@ internal void GewichtungSetzen(Gewichtungsziel ziel, Gewichtung wert)
internal IReadOnlyList Zellmeldungen(string mannschaft, MannschaftsKostenart art, Planstand? angezeigt) =>
sitzung is Plansitzung s && angezeigt is not null ? s.Meldungen(angezeigt.Spiele, mannschaft, art) : [];
+ ///
+ /// Einzelheiten eines Kriteriums im angezeigten Plan (Qualitätsansicht): je betroffener Mannschaft ihre Verstöße, die
+ /// meisten zuerst; bei den harten Fehlern die Fehler des ganzen Plans. Leer, wenn es keine gibt oder das Kriterium
+ /// keine Einzelangaben hat (Spieltage, vereinsinterne Spiele).
+ ///
+ /// true für die harten Fehler (A1), sonst gilt .
+ /// Das Kriterium.
+ /// Der angezeigte Plan.
+ /// Die Einzelheiten.
+ internal IReadOnlyList Kriteriumsdetails(bool harteFehler, Kostenkriterium? kriterium, Planstand? angezeigt)
+ {
+ if (sitzung is not Plansitzung s || angezeigt is null)
+ {
+ return [];
+ }
+
+ if (harteFehler)
+ {
+ IReadOnlyList fehler = s.HarteFehler(angezeigt.Spiele);
+ if (fehler.Count == 0)
+ {
+ return [];
+ }
+
+ return [new Meldungsgruppe("Ganzer Plan", string.Create(Deutsch, $"{fehler.Count} Fehler"), fehler)];
+ }
+
+ if (kriterium is not Kostenkriterium k || k >= Kostenkriterium.VereinsinterneSpieleAmAnfang)
+ {
+ return [];
+ }
+
+ var art = (MannschaftsKostenart)(int)k;
+ Dictionary> texte = s.MeldungenJeMannschaft(angezeigt.Spiele, art)
+ .GroupBy(m => m.Mannschaft, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Meldungen, StringComparer.Ordinal);
+ return angezeigt.Bewertung.Mannschaften
+ .Select(m => (m.Name, Zelle: m.JeKostenart[(int)art], Texte: texte.GetValueOrDefault(m.Name) ?? []))
+ .Where(x => x.Zelle.Anzahl > 0 || x.Texte.Count > 0 || (x.Zelle.Anzahl < 0 && x.Zelle.Kosten > 0))
+ .OrderByDescending(x => Math.Max(x.Zelle.Anzahl, x.Texte.Count))
+ .ThenByDescending(x => x.Zelle.Kosten)
+ .Select(x => new Meldungsgruppe(x.Name, Anzahltext(x.Zelle.Anzahl, x.Texte.Count), x.Texte))
+ .ToList();
+ }
+
///
/// Übernimmt geänderte Optionen wie das Original (DoOnAfterPlanChanged): speichern, laufende Generierung vom
/// besten Plan aus mit den neuen Optionen fortsetzen, alle Ansichten neu bewerten.
@@ -591,47 +766,6 @@ internal Druckdokument DruckdokumentErstellen(DruckauswahlViewModel auswahl)
return new Druckdokument(s.Staffel.Name, plan, DateTime.Now, abschnitte);
}
- ///
- /// Speichert einen Plan als Excel-Arbeitsmappe (Übersicht, Spielplan, Mannschaftspläne, Kosten) und öffnet sie
- /// anschließend mit dem zugeordneten Programm.
- ///
- /// Die Planquelle.
- /// Erledigt nach dem Export.
- internal async Task ExcelExportierenAsync(PlanQuelle quelle)
- {
- ArgumentNullException.ThrowIfNull(quelle);
- if (sitzung is not Plansitzung s || PlanFuer(quelle) is not Planstand stand)
- {
- return;
- }
-
- const string Titeltext = "Nach Excel exportieren";
- string? pfad = await oberflaeche.DateiSpeichernAsync(Titeltext, s.Staffel.Name + ".xlsx", new Dateifilter("Excel-Arbeitsmappen", ["*.xlsx"]));
- if (pfad is null)
- {
- return;
- }
-
- try
- {
- Arbeitsmappe mappe = Tabellenexport.Erstellen(
- s.Staffel,
- quelle.Name,
- DateTime.Now,
- s.Terminplan(stand.Spiele),
- Kostendarstellung.Kennzahlen(stand.Bewertung, s.Optionen),
- Kostendarstellung.Tabelle(stand.Bewertung, s.Optionen, s.Staffel));
- XlsxDatei.Speichern(pfad, mappe);
- }
- catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
- {
- await oberflaeche.MeldenAsync(Titeltext, fehler.Message);
- return;
- }
-
- await oberflaeche.DateiAnzeigenAsync(pfad);
- }
-
/// Daten der Diagramme eines Plans.
/// Plan.
/// Die Diagrammdaten oder null, solange keine Staffel geöffnet ist.
@@ -641,6 +775,16 @@ internal async Task ExcelExportierenAsync(PlanQuelle quelle)
/// Die Daten oder null, solange keine Staffel geöffnet ist.
internal (DateOnly Mitte1, DateOnly Mitte2)? AutomatischeRundenmitten() => sitzung?.AutomatischeRundenmitten();
+ ///
+ /// Der angezeigte Plan hat sich geändert (aktive Ansicht, ihre Planquelle, Generierung): Export für click-TT neu
+ /// bestimmen.
+ ///
+ internal void ExportAktualisieren()
+ {
+ CsvHinweis = ExportQuelle() is PlanQuelle quelle ? "Für click-TT exportieren: " + quelle.Name : KeinExportplan;
+ CsvExportierenCommand.NotifyCanExecuteChanged();
+ }
+
/// Löscht einen gemerkten Plan nach Rückfrage.
/// Quelle des gemerkten Plans.
/// Erledigt nach Rückfrage und Löschen.
@@ -669,6 +813,30 @@ internal async Task GemerktenPlanEntfernenAsync(PlanQuelle quelle)
QuellenNeuAufbauen();
}
+ /// Die Einteilung als Aufzählung für die Einrichtung: je Stufe eine Überschrift, darunter die Kriterien mit Nummer.
+ private static List Einteilungszeilen(Stufeneinteilung einteilung)
+ {
+ Stufeneinteilung e = einteilung.Vervollstaendigt();
+ var zeilen = new List { "Stufe A", " A1 Harte Fehler (fest)" };
+ zeilen.AddRange(e.A.Select((k, i) => string.Create(Deutsch, $" A{i + 2} {Planqualitaet.Name(k)}")));
+ zeilen.Add(string.Empty);
+ zeilen.Add("Stufe B");
+ zeilen.AddRange(e.B.Select((k, i) => string.Create(Deutsch, $" B{i + 1} {Planqualitaet.Name(k)}")));
+ zeilen.Add(string.Empty);
+ zeilen.Add("Stufe C");
+ zeilen.AddRange(e.C.Select((k, i) => string.Create(Deutsch, $" C{i + 1} {Planqualitaet.Name(k)}")));
+ return zeilen;
+ }
+
+ private static string Anzahltext(int verstoesse, int meldungen) => (verstoesse, meldungen) switch
+ {
+ (1, _) => "1 Verstoß",
+ (> 1, _) => string.Create(Deutsch, $"{verstoesse} Verstöße"),
+ (_, 1) => "1 Meldung",
+ (_, > 1) => string.Create(Deutsch, $"{meldungen} Meldungen"),
+ _ => "Abweichung",
+ };
+
private static IReadOnlyList DetailMeldungen(Plansitzung s, MannschaftsKostenartGewichtungsziel ziel, Planstand? angezeigt)
{
IReadOnlyList meldungen = angezeigt is null ? [] : s.Meldungen(angezeigt.Spiele, ziel.Name, ziel.Art);
@@ -763,6 +931,11 @@ private static string Statustext(Optimierungsstand stand)
text += $" · Spezial-Insel: {Referenzbewertung.Kostenartnamen[(int)art]}";
}
+ if (stand.Lenkung is { } lenkung)
+ {
+ text += $" · Automodus: {lenkung.Bezeichnung}";
+ }
+
return stand.Pausiert ? "Angehalten – " + text : text;
}
@@ -871,6 +1044,8 @@ private async Task SchliessenAsync()
OnPropertyChanged(nameof(StartplanWaehlbar));
StartenCommand.NotifyCanExecuteChanged();
GenerierungCommand.NotifyCanExecuteChanged();
+ KostenoptimierungCommand.NotifyCanExecuteChanged();
+ AutomodusCommand.NotifyCanExecuteChanged();
EinstellungenCommand.NotifyCanExecuteChanged();
DatenCommand.NotifyCanExecuteChanged();
DruckenCommand.NotifyCanExecuteChanged();
@@ -915,6 +1090,8 @@ private async Task StaffelOeffnenAsync(string pfad)
OnPropertyChanged(nameof(StartplanWaehlbar));
StartenCommand.NotifyCanExecuteChanged();
GenerierungCommand.NotifyCanExecuteChanged();
+ KostenoptimierungCommand.NotifyCanExecuteChanged();
+ AutomodusCommand.NotifyCanExecuteChanged();
EinstellungenCommand.NotifyCanExecuteChanged();
DatenCommand.NotifyCanExecuteChanged();
DruckenCommand.NotifyCanExecuteChanged();
@@ -952,6 +1129,24 @@ private async Task StaffelOeffnenAsync(string pfad)
]));
}
+ // Eigene Einteilung der Kriterien für den Automodus (Qualitätsansicht): wie die gespeicherten Einstellungen zur Wahl.
+ Einrichtungsschritt? stufenschritt = null;
+ if (!string.IsNullOrWhiteSpace(neu.Staffel.Kriterienstufen)
+ && Stufeneinteilung.Lesen(neu.Staffel.Kriterienstufen) is Stufeneinteilung gespeichert
+ && gespeichert.Text() != Stufeneinteilung.Standard.Text())
+ {
+ stufenschritt = new Einrichtungsschritt(
+ schritte.Count + 1,
+ "Gespeicherte Kriterienreihenfolge",
+ "Bei der letzten Verwendung wurde zu dieser Staffel folgende Einteilung der Kriterien hinterlegt. Nach ihr richtet sich der Automodus:",
+ Einteilungszeilen(gespeichert),
+ [
+ new Einrichtungsoption("Diese Reihenfolge verwenden", "wie bei der letzten Verwendung"),
+ new Einrichtungsoption("Standardreihenfolge verwenden", "nur für diese Sitzung; die gespeicherte bleibt erhalten"),
+ ]);
+ schritte.Add(stufenschritt);
+ }
+
Rundenvorschlag vorschlag = neu.RundeVorschlagen(DateOnly.FromDateTime(DateTime.Today));
Einrichtungsschritt? rundenschritt = null;
if (vorschlag != Rundenvorschlag.Keiner)
@@ -980,7 +1175,7 @@ private async Task StaffelOeffnenAsync(string pfad)
: new EinrichtungViewModel(
neu.Staffel.Name + " einrichten",
schritte,
- schritt => SchrittUebernehmenAsync(neu, schritt, ReferenceEquals(schritt, rundenschritt) ? vorschlag : Rundenvorschlag.Keiner),
+ schritt => EinrichtungsschrittUebernehmenAsync(neu, schritt, stufenschritt, rundenschritt, vorschlag),
schritt => SeiteErzeugen(neu, schritt),
EinrichtungAbschliessen);
}
@@ -1004,6 +1199,39 @@ private async Task StaffelOeffnenAsync(string pfad)
return new DatenDialogViewModel(bearbeitung, [seite]);
}
+ /// Übernimmt einen Punkt der Einrichtung: Kriterienreihenfolge, Rundenplanung (mit Vorschlag) oder einen anderen Punkt.
+ private Task EinrichtungsschrittUebernehmenAsync(
+ Plansitzung s,
+ Einrichtungsschritt schritt,
+ Einrichtungsschritt? stufenschritt,
+ Einrichtungsschritt? rundenschritt,
+ Rundenvorschlag vorschlag)
+ {
+ if (ReferenceEquals(schritt, stufenschritt))
+ {
+ return Task.FromResult(KriterienschrittUebernehmen(s, schritt));
+ }
+
+ return SchrittUebernehmenAsync(s, schritt, ReferenceEquals(schritt, rundenschritt) ? vorschlag : Rundenvorschlag.Keiner);
+ }
+
+ /// Einrichtung: gespeicherte Kriterienreihenfolge verwenden oder für diese Sitzung die Standardreihenfolge.
+ private bool KriterienschrittUebernehmen(Plansitzung s, Einrichtungsschritt schritt)
+ {
+ if (!ReferenceEquals(s, sitzung))
+ {
+ return false;
+ }
+
+ if (schritt.Auswahl == 1)
+ {
+ s.StandardKriterienstufenFuerDieseSitzung();
+ AlleAnsichtenAktualisieren();
+ }
+
+ return true;
+ }
+
///
/// Übernimmt einen Punkt der Einrichtung (Original: „Ja“ bzw. OK im jeweiligen Dialog). ist
/// nur beim Punkt „Rundenplanung“ gesetzt.
@@ -1056,14 +1284,9 @@ private async Task RundeUebernehmenAsync(Plansitzung s, Rundenvorschlag vo
}
}
- private Task EinrichtungAbschliessen(bool generieren)
+ private Task EinrichtungAbschliessen()
{
Einrichtung = null;
- if (generieren && IstGeoeffnet && !Laeuft)
- {
- Starten();
- }
-
return Task.CompletedTask;
}
@@ -1104,6 +1327,7 @@ private void Starten()
IReadOnlyList? ausgangsplan = startplan.Quelle is PlanQuelle quelle ? PlanFuer(quelle)?.Spiele : null;
laufenderStand = null;
dienst.Pausiert = false;
+ dienst.Automodus = Automodus;
Anzeige.Zuruecksetzen("erster Plan wird gesucht …");
dienst.Starten(s.Staffel, s.Optionen, ausgangsplan);
StartplaeneAufbauen();
@@ -1111,6 +1335,7 @@ private void Starten()
Pausiert = false;
BefehleAktualisieren();
AnsichtenAktualisieren(PlanQuellenArt.LaufendeGenerierung);
+ VerfahrensansichtZeigen();
}
private void GenerierungUmschalten()
@@ -1125,12 +1350,76 @@ private void GenerierungUmschalten()
}
}
+ ///
+ /// Auswahl im Menü des Startknopfs: vor dem Start wird mit diesem Verfahren gestartet; während der Generierung wird zu
+ /// ihm gewechselt, und die Suche setzt beim bisher besten Plan fort (auch aus der Pause heraus).
+ ///
+ private void VerfahrenWaehlen(bool auto)
+ {
+ Automodus = auto;
+ if (!Laeuft)
+ {
+ Starten();
+ return;
+ }
+
+ if (sitzung is Plansitzung s)
+ {
+ dienst.Automodus = auto;
+ dienst.DatenAendern(s.Staffel, s.Optionen);
+ Anzeige.KostenAngepasst();
+ dienst.Pausiert = false;
+ Pausiert = false;
+ VerfahrensansichtZeigen();
+ }
+ }
+
+ ///
+ /// Zum Verfahren passende Ansicht zeigen (vorher öffnen, falls nötig): im Automodus die Qualitätsansicht, in der der
+ /// Staffelleiter lenkt, bei der Kostenoptimierung die Kostenansicht.
+ ///
+ private void VerfahrensansichtZeigen()
+ {
+ if (!ZeigtArbeitsbereich)
+ {
+ return;
+ }
+
+ if (Automodus)
+ {
+ AnsichtZeigen(() => new QualitaetAnsichtViewModel(this, NaechsteId()));
+ }
+ else
+ {
+ AnsichtZeigen(() => new KostenAnsichtViewModel(this, NaechsteId()));
+ }
+ }
+
+ private string Menueeintrag(string verfahren, bool gewaehlt) => (Laeuft, gewaehlt) switch
+ {
+ (false, _) => verfahren + " starten",
+ (true, true) => verfahren + " (läuft)",
+ _ => (verfahren == "Automodus" ? "Zum " : "Zur ") + verfahren + " wechseln",
+ };
+
+ private void VerfahrenGeaendert()
+ {
+ OnPropertyChanged(nameof(GenerierungText));
+ OnPropertyChanged(nameof(KostenoptimierungText));
+ OnPropertyChanged(nameof(AutomodusText));
+ OnPropertyChanged(nameof(VerfahrenHinweis));
+ KostenoptimierungCommand.NotifyCanExecuteChanged();
+ AutomodusCommand.NotifyCanExecuteChanged();
+ }
+
private void GenerierungGeaendert()
{
+ VerfahrenGeaendert();
OnPropertyChanged(nameof(GenerierungText));
OnPropertyChanged(nameof(ZeigtPause));
OnPropertyChanged(nameof(ZeigtStart));
OnPropertyChanged(nameof(StartplanWaehlbar));
+ PlanLoeschenAktualisieren();
}
private void KopfAktualisieren(Plansitzung s)
@@ -1232,9 +1521,10 @@ private async Task PlanMerkenAsync()
QuellenNeuAufbauen();
}
+ /// Exportiert den aktuell angezeigten Plan als CSV für den click-TT-Import.
private async Task CsvExportierenAsync()
{
- if (sitzung is not Plansitzung s || laufenderStand is not Planstand stand)
+ if (sitzung is not Plansitzung s || ExportQuelle() is not PlanQuelle quelle || PlanFuer(quelle) is not Planstand stand)
{
return;
}
@@ -1394,6 +1684,12 @@ private void AnsichtOeffnen(AnsichtViewModel ansicht)
ansichten.Add(ansicht);
fabrik.AnsichtHinzufuegen(ansicht);
ansicht.QuellenUebernehmen(Planquellen);
+
+ // Vor dem Start zeigt eine neue Ansicht den gewählten Ausgangsplan.
+ if (!Laeuft && startplan.Quelle is PlanQuelle quelle && Planquellen.Contains(quelle))
+ {
+ ansicht.Quelle = quelle;
+ }
}
private Planstand? GemerktenStandLaden(Plansitzung s, GemerkterPlanEintrag eintrag)
@@ -1441,6 +1737,47 @@ private void QuellenNeuAufbauen()
}
/// Ausgangspläne für „Generierung starten“: leer, bester Plan der letzten Generierung, click-TT-Plan, gemerkte Pläne.
+ private void PlanLoeschenAktualisieren()
+ {
+ OnPropertyChanged(nameof(ZeigtPlanLoeschen));
+ PlanLoeschenCommand.NotifyCanExecuteChanged();
+ }
+
+ ///
+ /// Der aktuell angezeigte Plan: der Plan der aktiven Ansicht, sonst der Stand der Generierung, sonst der gewählte
+ /// Ausgangsplan; null, wenn es keinen gibt.
+ ///
+ private PlanQuelle? ExportQuelle()
+ {
+ if (sitzung is null || !ZeigtArbeitsbereich)
+ {
+ return null;
+ }
+
+ PlanQuelle? quelle = ((Layout.ActiveDockable as IDock)?.ActiveDockable as AnsichtViewModel)?.Quelle;
+ quelle ??= laufenderStand is not null ? PlanQuelle.Laufend : startplan.Quelle;
+ return quelle is not null && PlanFuer(quelle) is not null ? quelle : null;
+ }
+
+ /// Zeigt den gewählten Ausgangsplan vor dem Start: alle Ansichten auf diesen Plan, die Kacheln mit seiner Bewertung.
+ private void StartplanZeigen(PlanQuelle quelle)
+ {
+ if (Laeuft || sitzung is null)
+ {
+ return;
+ }
+
+ foreach (AnsichtViewModel ansicht in ansichten.Where(a => a.Quellen.Contains(quelle)))
+ {
+ ansicht.Quelle = quelle;
+ }
+
+ if (PlanFuer(quelle) is Planstand stand)
+ {
+ Anzeige.PlanZeigen(stand.Bewertung, Einteilung, quelle.Name);
+ }
+ }
+
private void StartplaeneAufbauen()
{
var starts = new List { Startplan.Leer };
@@ -1451,6 +1788,7 @@ private void StartplaeneAufbauen()
Startplaene = starts;
startplan = starts.FirstOrDefault(p => p == bisher) ?? Startplan.Leer;
OnPropertyChanged(nameof(Startplan));
+ PlanLoeschenAktualisieren();
}
private void AlleAnsichtenAktualisieren()
@@ -1472,7 +1810,7 @@ private void AnsichtenAktualisieren(PlanQuellenArt art)
private void BefehleAktualisieren()
{
PlanMerkenCommand.NotifyCanExecuteChanged();
- CsvExportierenCommand.NotifyCanExecuteChanged();
+ ExportAktualisieren();
AnsichtsbefehleAktualisieren();
}
@@ -1481,6 +1819,7 @@ private void AnsichtsbefehleAktualisieren()
OnPropertyChanged(nameof(HatPlan));
KostenansichtCommand.NotifyCanExecuteChanged();
QualitaetsansichtCommand.NotifyCanExecuteChanged();
+ ErgebnisCommand.NotifyCanExecuteChanged();
MeldungsansichtCommand.NotifyCanExecuteChanged();
TerminplanansichtCommand.NotifyCanExecuteChanged();
DiagrammansichtCommand.NotifyCanExecuteChanged();
@@ -1525,7 +1864,7 @@ private void BeiVerbesserung(object? sender, Optimierungsstand stand)
bool erster = laufenderStand is null;
laufenderStand = neu;
- Anzeige.Uebernehmen(neu.Bewertung);
+ Anzeige.Uebernehmen(neu.Bewertung, Einteilung);
if (erster)
{
StartplaeneAufbauen();
diff --git a/src/AndiGenerator.Presentation/QualitaetAnsichtViewModel.cs b/src/AndiGenerator.Presentation/QualitaetAnsichtViewModel.cs
index 203ce32..67fab34 100644
--- a/src/AndiGenerator.Presentation/QualitaetAnsichtViewModel.cs
+++ b/src/AndiGenerator.Presentation/QualitaetAnsichtViewModel.cs
@@ -4,19 +4,34 @@
using System.Globalization;
using AndiGenerator.Application;
+using AndiGenerator.Engine.Inseln;
using AndiGenerator.Engine.Referenz;
+using CommunityToolkit.Mvvm.Input;
namespace AndiGenerator.Presentation;
///
-/// Qualitätsansicht: Verstöße je Kriterium in der Rangfolge aus MIGRATIONSPLAN E14 (A = Muss, B = Sperr- und
-/// Ausweichtermine, C = übrige). Zeigt auf einen Blick, was an einem Plan noch stört – unabhängig von der Gewichtung.
+/// Qualitätsansicht: Verstöße je Kriterium in der Rangfolge aus MIGRATIONSPLAN E14. Im Automodus legt der Staffelleiter
+/// hier die Einteilung in die Stufen A, B und C und die Reihenfolge darin fest – mit Knöpfen oder durch Ziehen und Ablegen
+/// mit Vorschau (gespeichert mit den Spielplandaten); der Automodus lenkt danach. Kosten zeigt die Ansicht nur bei der
+/// Kostenoptimierung.
///
public sealed class QualitaetAnsichtViewModel : AnsichtViewModel
{
private IReadOnlyList zeilen = [];
private string zusammenfassung = string.Empty;
private bool pflichtErfuellt = true;
+ private bool zeigtKosten = true;
+ private bool bearbeitbar;
+ private Planstand? letzter;
+ private Qualitaetszeile? schwebend;
+ private Kostenkriterium? gezogen;
+ private Stufeneinteilung? ausgangslage;
+ private Stufeneinteilung? vorschau;
+ private string? gewaehlt;
+ private (Planstand? Stand, string? Name) detailsFuer;
+ private IReadOnlyList details = [];
+ private string detailTitel = string.Empty;
/// Initialisiert die Ansicht.
/// Hauptfenster.
@@ -24,6 +39,7 @@ public sealed class QualitaetAnsichtViewModel : AnsichtViewModel
public QualitaetAnsichtViewModel(HauptfensterViewModel hauptfenster, string id)
: base(hauptfenster, "Qualität", id)
{
+ DetailsSchliessenCommand = new RelayCommand(() => Waehlen(null));
}
/// Holt die Kriterien.
@@ -33,6 +49,32 @@ public IReadOnlyList Zeilen
private set => SetProperty(ref zeilen, value);
}
+ /// Holt die Verstöße des gewählten Kriteriums je Mannschaft (rechts neben der Tabelle); leer = nichts anzeigen.
+ public IReadOnlyList Details
+ {
+ get => details;
+ private set
+ {
+ if (SetProperty(ref details, value))
+ {
+ OnPropertyChanged(nameof(ZeigtDetails));
+ }
+ }
+ }
+
+ /// Holt einen Wert, der angibt, ob rechts Verstöße angezeigt werden.
+ public bool ZeigtDetails => Details.Count > 0;
+
+ /// Holt die Überschrift der Verstöße, z. B. B3 · Sperrtermine.
+ public string DetailTitel
+ {
+ get => detailTitel;
+ private set => SetProperty(ref detailTitel, value);
+ }
+
+ /// Holt den Befehl, der die Verstöße rechts schließt (Auswahl aufheben).
+ public IRelayCommand DetailsSchliessenCommand { get; }
+
/// Holt die Zusammenfassung, z. B. Pflichtstufe A erfüllt · 3 Kriterien mit Verstößen.
public string Zusammenfassung
{
@@ -47,40 +89,244 @@ public bool PflichtErfuellt
private set => SetProperty(ref pflichtErfuellt, value);
}
+ /// Holt einen Wert, der angibt, ob die Kosten gezeigt werden (nur bei der Kostenoptimierung).
+ public bool ZeigtKosten
+ {
+ get => zeigtKosten;
+ private set => SetProperty(ref zeigtKosten, value);
+ }
+
+ /// Holt einen Wert, der angibt, ob die Einteilung bearbeitet werden kann (nur im Automodus).
+ public bool Bearbeitbar
+ {
+ get => bearbeitbar;
+ private set => SetProperty(ref bearbeitbar, value);
+ }
+
+ ///
+ /// Holt die gezogene Zeile, wie sie am Mauszeiger hängt (mit der Stufe, die sie beim Ablegen bekäme); null,
+ /// solange nicht gezogen wird. In der Tabelle steht an ihrer Stelle eine Lücke.
+ ///
+ public Qualitaetszeile? Schwebend
+ {
+ get => schwebend;
+ private set => SetProperty(ref schwebend, value);
+ }
+
+ ///
+ /// Wählt eine Zeile: rechts daneben stehen ihre Verstöße je Mannschaft. Ein zweiter Klick auf dieselbe Zeile oder
+ /// null hebt die Auswahl auf. Gibt es keine Verstöße, bleibt die rechte Tabelle zu.
+ ///
+ /// Die Zeile oder null.
+ public void Waehlen(Qualitaetszeile? zeile)
+ {
+ gewaehlt = zeile is null || zeile.Name == gewaehlt ? null : zeile.Name;
+ AuswahlZeigen();
+ }
+
+ /// Beginnt das Ziehen einer Zeile (nur im Automodus, nicht bei den harten Fehlern).
+ /// Die Zeile.
+ /// true, wenn gezogen wird.
+ public bool ZiehenBeginnen(Qualitaetszeile zeile)
+ {
+ ArgumentNullException.ThrowIfNull(zeile);
+ if (!Bearbeitbar || zeile.Kriterium is not Kostenkriterium k)
+ {
+ return false;
+ }
+
+ gezogen = k;
+ ausgangslage = Hauptfenster.Einteilung.Vervollstaendigt();
+ vorschau = ausgangslage;
+ Neu();
+ return true;
+ }
+
+ ///
+ /// Die gezogene Zeile ist über : Vorschau, wie die Tabelle nach dem Ablegen aussähe (Stufe und
+ /// Nummer ergeben sich aus der Stelle). Über der oberen Hälfte einer Zeile kommt sie davor, sonst dahinter.
+ ///
+ /// Die Zeile unter dem Zeiger.
+ /// true = obere Hälfte.
+ public void ZiehenUeber(Qualitaetszeile ziel, bool davor)
+ {
+ ArgumentNullException.ThrowIfNull(ziel);
+ if (gezogen is not Kostenkriterium k || ausgangslage is null || ziel.Kriterium is not Kostenkriterium nachbar || nachbar == k)
+ {
+ return;
+ }
+
+ Stufeneinteilung neu = ausgangslage.Platzieren(k, nachbar, davor);
+ if (neu.Text() != vorschau?.Text())
+ {
+ vorschau = neu;
+ Neu();
+ }
+ }
+
+ /// Beendet das Ziehen: übernimmt die Vorschau (Ablegen) oder verwirft sie (Abbrechen).
+ /// true = ablegen.
+ public void ZiehenBeenden(bool uebernehmen)
+ {
+ Stufeneinteilung? neu = vorschau;
+ Stufeneinteilung? alt = ausgangslage;
+ gezogen = null;
+ vorschau = null;
+ ausgangslage = null;
+ if (uebernehmen && neu is not null && alt is not null && neu.Text() != alt.Text())
+ {
+ _ = Hauptfenster.EinteilungAendernAsync(_ => neu);
+ }
+
+ Neu();
+ }
+
+ /// Das Verfahren hat gewechselt: Kosten und Bearbeitung zeigen oder ausblenden.
+ internal void VerfahrenGeaendert() => Neu();
+
///
protected override void Anzeigen(Planstand? stand)
{
+ letzter = stand;
+ ZeigtKosten = !Hauptfenster.Automodus;
+ Bearbeitbar = Hauptfenster.Automodus;
if (stand is null)
{
Zeilen = [];
Zusammenfassung = string.Empty;
PflichtErfuellt = true;
+ AuswahlZeigen();
return;
}
- IReadOnlyList kriterien = Planqualitaet.Kriterien(stand.Bewertung);
- Zeilen = kriterien.Select(Zeile).ToList();
+ Stufeneinteilung einteilung = vorschau ?? Hauptfenster.Einteilung.Vervollstaendigt();
+ IReadOnlyList kriterien = Planqualitaet.Kriterien(stand.Bewertung, einteilung);
+
+ // Gleiche Zeilen (Stufe und Kriterium) werden nur aktualisiert, damit eine offene Auswahl nicht verschwindet.
+ bool gleich = Zeilen.Count == kriterien.Count && Zeilen.Zip(kriterien).All(p => p.First.Stufe == p.Second.Stufe && p.First.Name == p.Second.Name);
+ if (!gleich)
+ {
+ Zeilen = kriterien.Select((k, i) => NeueZeile(k, Nachbar(kriterien, i, -1), Nachbar(kriterien, i, +1))).ToList();
+ }
+
+ foreach ((Qualitaetszeile zeile, Qualitaetskriterium k) in Zeilen.Zip(kriterien))
+ {
+ Werte(zeile, k);
+ }
+
+ Schwebend = gezogen is null ? null : SchwebendeZeile(kriterien);
+ AuswahlZeigen();
+
int pflicht = kriterien.Count(k => k.IstPflicht && !k.Erfuellt);
int uebrige = kriterien.Count(k => !k.IstPflicht && !k.Erfuellt);
PflichtErfuellt = pflicht == 0;
string a = pflicht == 0 ? "Pflichtstufe A erfüllt" : $"Pflichtstufe A: {pflicht} Kriterien verletzt";
- Zusammenfassung = $"{a} · {uebrige} weitere Kriterien mit Verstößen · Gesamtkosten {Kostenanzeige.Kurz(stand.Bewertung.Gesamtkosten)}";
+ Zusammenfassung = ZeigtKosten
+ ? $"{a} · {uebrige} weitere Kriterien mit Verstößen · Gesamtkosten {Kostenanzeige.Kurz(stand.Bewertung.Gesamtkosten)}"
+ : $"{a} · {uebrige} weitere Kriterien mit Verstößen";
+ }
+
+ /// Das sichtbare Nachbarkriterium in derselben Stufe (−1 darüber, +1 darunter); null am Rand der Stufe.
+ private static Kostenkriterium? Nachbar(IReadOnlyList kriterien, int index, int richtung)
+ {
+ int j = index + richtung;
+ if (kriterien[index].Kriterium is null || j < 0 || j >= kriterien.Count || kriterien[j].Kriterium is not Kostenkriterium nachbar)
+ {
+ return null;
+ }
+
+ return kriterien[j].Stufe[0] == kriterien[index].Stufe[0] ? nachbar : null;
}
- private static Qualitaetszeile Zeile(Qualitaetskriterium k)
+ /// Verschiebt an vorbei (dazwischen liegen ggf. ausgeblendete Kriterien).
+ private static Stufeneinteilung Vorbei(Stufeneinteilung e, Kostenkriterium k, Kostenkriterium nachbar, int richtung)
{
- string verstoesse = k.Anzahl switch
+ Stufeneinteilung neu = e;
+ for (int i = 0; i < Stufeneinteilung.AnzahlKriterien && Math.Sign(neu.Position(nachbar) - neu.Position(k)) == richtung; i++)
+ {
+ neu = neu.Verschieben(k, richtung);
+ }
+
+ return neu;
+ }
+
+ private static void Werte(Qualitaetszeile zeile, Qualitaetskriterium k)
+ {
+ zeile.Verstoesse = k.Anzahl switch
{
null => "–",
int anzahl when k.Gesamtzahl is int von => $"{anzahl} von {von}",
int anzahl => anzahl.ToString(CultureInfo.InvariantCulture),
};
- Qualitaetszustand zustand = (k.Erfuellt, k.IstPflicht) switch
+ zeile.Kosten = Kostenanzeige.Kurz(k.Kosten);
+ zeile.Zustand = (k.Erfuellt, k.IstPflicht) switch
{
(true, _) => Qualitaetszustand.Erfuellt,
(false, true) => Qualitaetszustand.PflichtVerletzt,
_ => Qualitaetszustand.Verletzt,
};
- return new Qualitaetszeile(k.Stufe, k.Name, verstoesse, Kostenanzeige.Kurz(k.Kosten), zustand);
+ }
+
+ /// Kopie der gezogenen Zeile für den Mauszeiger (nicht bearbeitbar, mit ihren Werten).
+ private Qualitaetszeile? SchwebendeZeile(IReadOnlyList kriterien)
+ {
+ if (kriterien.FirstOrDefault(k => k.Kriterium == gezogen) is not Qualitaetskriterium k)
+ {
+ return null;
+ }
+
+ var zeile = new Qualitaetszeile(k.Stufe, k.Name, k.Kriterium);
+ Werte(zeile, k);
+ return zeile;
+ }
+
+ /// Markiert die gewählte Zeile und bildet ihre Verstöße (nur neu, wenn sich Plan oder Auswahl geändert haben).
+ private void AuswahlZeigen()
+ {
+ Qualitaetszeile? zeile = Zeilen.FirstOrDefault(z => z.Name == gewaehlt);
+ foreach (Qualitaetszeile z in Zeilen)
+ {
+ z.IstGewaehlt = z == zeile;
+ }
+
+ DetailTitel = zeile is null ? string.Empty : $"{zeile.Stufe} · {zeile.Name}";
+ if (detailsFuer != (letzter, zeile?.Name))
+ {
+ detailsFuer = (letzter, zeile?.Name);
+ Details = zeile is null ? [] : Hauptfenster.Kriteriumsdetails(zeile.Kriterium is null, zeile.Kriterium, letzter);
+ }
+ }
+
+ /// Baut die Zeilen neu auf (Bearbeitbarkeit oder Vorschau geändert).
+ private void Neu()
+ {
+ Zeilen = [];
+ Anzeigen(letzter);
+ }
+
+ private Qualitaetszeile NeueZeile(Qualitaetskriterium k, Kostenkriterium? oben, Kostenkriterium? unten)
+ {
+ if (k.Kriterium is not Kostenkriterium kriterium || !Bearbeitbar)
+ {
+ return new Qualitaetszeile(k.Stufe, k.Name, k.Kriterium);
+ }
+
+ // Hoch/runter innerhalb der Stufe; am Rand der Stufe in die Nachbarstufe: nach unten an deren erste Stelle,
+ // nach oben ans Ende (Stufeneinteilung.Einstufen). Spieltage ohne Anzahl bleiben in C.
+ Stufe klasse = (Stufe)(k.Stufe[0] - 'A');
+ Action? hoch = oben switch
+ {
+ Kostenkriterium o => () => _ = Hauptfenster.EinteilungAendernAsync(e => Vorbei(e, kriterium, o, -1)),
+ null when klasse > Stufe.A && Stufeneinteilung.HatAnzahl(kriterium) => () => _ = Hauptfenster.EinteilungAendernAsync(e => e.Einstufen(kriterium, klasse - 1)),
+ _ => null,
+ };
+ Action? runter = unten switch
+ {
+ Kostenkriterium u => () => _ = Hauptfenster.EinteilungAendernAsync(e => Vorbei(e, kriterium, u, +1)),
+ null when klasse < Stufe.C => () => _ = Hauptfenster.EinteilungAendernAsync(e => e.Einstufen(kriterium, klasse + 1)),
+ _ => null,
+ };
+ var bearbeitung = new Zeilenbearbeitung((x, stufe) => _ = Hauptfenster.EinteilungAendernAsync(e => e.Einstufen(x, stufe)), hoch, runter);
+ return new Qualitaetszeile(k.Stufe, k.Name, kriterium, bearbeitung, kriterium == gezogen);
}
}
diff --git a/src/AndiGenerator.Presentation/Qualitaetszeile.cs b/src/AndiGenerator.Presentation/Qualitaetszeile.cs
index fc5b458..bf1315d 100644
--- a/src/AndiGenerator.Presentation/Qualitaetszeile.cs
+++ b/src/AndiGenerator.Presentation/Qualitaetszeile.cs
@@ -2,12 +2,124 @@
// SPDX-FileCopyrightText: 2026 Peter Buchmann
// SPDX-License-Identifier: GPL-3.0-only
+using AndiGenerator.Engine.Inseln;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Klassenstufe = AndiGenerator.Engine.Inseln.Stufe;
+
namespace AndiGenerator.Presentation;
-/// Zeile der Qualitätsansicht.
-/// Stufe nach E14, z. B. A2.
-/// Kriterium.
-/// Verstöße, z. B. 3 von 15 oder –, wenn nur Kosten bekannt sind.
-/// Kosten.
-/// Zustand für die Farbe.
-public sealed record Qualitaetszeile(string Stufe, string Name, string Verstoesse, string Kosten, Qualitaetszustand Zustand);
+///
+/// Zeile der Qualitätsansicht. Im Automodus lassen sich Kriterien (außer den harten Fehlern A1) einer anderen Stufe
+/// zuordnen und innerhalb ihrer Stufe verschieben – mit Knöpfen oder durch Ziehen; die Werte werden bei jedem besseren
+/// Plan erneuert.
+///
+public sealed class Qualitaetszeile : ObservableObject
+{
+ private readonly Zeilenbearbeitung? bearbeitung;
+ private string verstoesse = string.Empty;
+ private string kosten = string.Empty;
+ private Qualitaetszustand zustand;
+ private bool istGewaehlt;
+
+ /// Initialisiert die Zeile; Verstöße, Kosten und Zustand werden danach gesetzt.
+ /// Stufe mit Nummer, z. B. A2.
+ /// Kriterium.
+ /// Das einstufbare Kriterium; null bei den harten Fehlern.
+ /// Einstufen und Verschieben; null = nicht bearbeitbar (z. B. außerhalb des Automodus).
+ /// Die Zeile wird gerade gezogen (Vorschau).
+ internal Qualitaetszeile(string stufe, string name, Kostenkriterium? kriterium = null, Zeilenbearbeitung? bearbeitung = null, bool gezogen = false)
+ {
+ Stufe = stufe;
+ Name = name;
+ Kriterium = kriterium;
+ IstGezogen = gezogen;
+ this.bearbeitung = kriterium is null ? null : bearbeitung;
+ EinstufenACommand = new RelayCommand(() => Einstufen(Klassenstufe.A), () => KannAB && Klasse != "A");
+ EinstufenBCommand = new RelayCommand(() => Einstufen(Klassenstufe.B), () => KannAB && Klasse != "B");
+ EinstufenCCommand = new RelayCommand(() => Einstufen(Klassenstufe.C), () => IstEinstufbar && Klasse != "C");
+ HochCommand = new RelayCommand(() => this.bearbeitung?.Hoch?.Invoke(), () => this.bearbeitung?.Hoch is not null);
+ RunterCommand = new RelayCommand(() => this.bearbeitung?.Runter?.Invoke(), () => this.bearbeitung?.Runter is not null);
+ }
+
+ /// Holt die Stufe mit Nummer, z. B. A2.
+ public string Stufe { get; }
+
+ /// Holt das Kriterium.
+ public string Name { get; }
+
+ /// Holt das einstufbare Kriterium; null bei den harten Fehlern.
+ public Kostenkriterium? Kriterium { get; }
+
+ /// Holt einen Wert, der angibt, ob die Zeile eingestuft und verschoben werden kann (im Automodus, alle außer A1).
+ public bool IstEinstufbar => bearbeitung is not null;
+
+ /// Holt einen Wert, der angibt, ob die Zeile gerade gezogen wird (Vorschau beim Ablegen).
+ public bool IstGezogen { get; }
+
+ /// Holt die Stufe ohne Nummer (A, B oder C).
+ public string Klasse => Stufe[..1];
+
+ /// Holt einen Wert, der angibt, ob die Zeile in Stufe A steht.
+ public bool IstA => Klasse == "A";
+
+ /// Holt einen Wert, der angibt, ob die Zeile in Stufe B steht.
+ public bool IstB => Klasse == "B";
+
+ /// Holt einen Wert, der angibt, ob die Zeile in Stufe C steht.
+ public bool IstC => Klasse == "C";
+
+ /// Holt einen Wert, der angibt, ob das Kriterium in A oder B stehen darf (nicht bei den Spieltagen ohne Anzahl).
+ public bool KannAB => IstEinstufbar && Kriterium is Kostenkriterium k && Stufeneinteilung.HatAnzahl(k);
+
+ /// Holt einen Wert, der angibt, ob die Zeile gewählt ist (ihre Verstöße stehen rechts daneben).
+ public bool IstGewaehlt
+ {
+ get => istGewaehlt;
+ internal set => SetProperty(ref istGewaehlt, value);
+ }
+
+ /// Holt die Verstöße, z. B. 3 von 15 oder –, wenn nur Kosten bekannt sind.
+ public string Verstoesse
+ {
+ get => verstoesse;
+ internal set => SetProperty(ref verstoesse, value);
+ }
+
+ /// Holt die Kosten.
+ public string Kosten
+ {
+ get => kosten;
+ internal set => SetProperty(ref kosten, value);
+ }
+
+ /// Holt den Zustand für die Farbe.
+ public Qualitaetszustand Zustand
+ {
+ get => zustand;
+ internal set => SetProperty(ref zustand, value);
+ }
+
+ /// Holt den Befehl „in Stufe A“.
+ public IRelayCommand EinstufenACommand { get; }
+
+ /// Holt den Befehl „in Stufe B“.
+ public IRelayCommand EinstufenBCommand { get; }
+
+ /// Holt den Befehl „in Stufe C“.
+ public IRelayCommand EinstufenCCommand { get; }
+
+ /// Holt den Befehl „nach oben“ (wichtiger innerhalb der Stufe).
+ public IRelayCommand HochCommand { get; }
+
+ /// Holt den Befehl „nach unten“ (weniger wichtig innerhalb der Stufe).
+ public IRelayCommand RunterCommand { get; }
+
+ private void Einstufen(Klassenstufe neu)
+ {
+ if (Kriterium is Kostenkriterium k)
+ {
+ bearbeitung?.Einstufen(k, neu);
+ }
+ }
+}
diff --git a/src/AndiGenerator.Presentation/Tabellenexport.cs b/src/AndiGenerator.Presentation/Tabellenexport.cs
deleted file mode 100644
index a8c5cdd..0000000
--- a/src/AndiGenerator.Presentation/Tabellenexport.cs
+++ /dev/null
@@ -1,153 +0,0 @@
-// SPDX-FileCopyrightText: 2017 Andreas Hofmann
-// SPDX-FileCopyrightText: 2026 Peter Buchmann
-// SPDX-License-Identifier: GPL-3.0-only
-
-using System.Globalization;
-using AndiGenerator.Domain.Stammdaten;
-using AndiGenerator.Engine.Referenz;
-using AndiGenerator.Persistence.Tabellen;
-using AndiGenerator.Rendering;
-
-namespace AndiGenerator.Presentation;
-
-///
-/// Baut die Excel-Arbeitsmappe eines Plans (neu, im Original nicht vorhanden): Übersicht, Spielplan und
-/// Mannschaftspläne als filterbare Listen mit echten Datums- und Zeitwerten, dazu die Kostentabelle wie in der Ansicht.
-///
-internal static class Tabellenexport
-{
- private const int Kopfhintergrund = 0xE8E8E8;
-
- private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE");
-
- /// Erzeugt die Arbeitsmappe.
- /// Die Staffel.
- /// Name der Planquelle.
- /// Zeitpunkt des Exports.
- /// Die Spiele des Plans mit Hinweisen.
- /// Die Kennzahlen der Kostenansicht.
- /// Die Kostentabelle.
- /// Die Arbeitsmappe.
- internal static Arbeitsmappe Erstellen(
- Staffel staffel, string plan, DateTime erstellt, IReadOnlyList spiele, IReadOnlyList kennzahlen, Kostentabelle kosten)
- {
- string gesamtkosten = kennzahlen.Count > 0 ? kennzahlen[0].Wert.Text : string.Empty;
- List blaetter =
- [
- Uebersicht(staffel, plan, erstellt, spiele, gesamtkosten),
- Spielplan(spiele),
- Mannschaftsplaene(spiele, staffel),
- Kosten(kennzahlen, kosten),
- ];
- return new Arbeitsmappe(blaetter);
- }
-
- private static Arbeitsblatt Uebersicht(Staffel staffel, string plan, DateTime erstellt, IReadOnlyList spiele, string gesamtkosten)
- {
- List> zeilen =
- [
- [new Blattzelle(UeberViewModel.Programm + " – Export", Fett: true)],
- [],
- Paar("Staffel", staffel.Name),
- Paar("Plan", plan),
- Paar("Erstellt", erstellt.ToString("dd.MM.yyyy HH:mm", Deutsch)),
- [Blattzelle.Text("Mannschaften", fett: true), Blattzelle.Zahl(staffel.Mannschaften.Count)],
- [Blattzelle.Text("Spiele", fett: true), Blattzelle.Zahl(spiele.Count)],
- [Blattzelle.Text("davon ohne Termin", fett: true), Blattzelle.Zahl(spiele.Count(s => s.Zeitpunkt is null))],
- Paar("Gesamtkosten", gesamtkosten),
- ];
- return new Arbeitsblatt("Übersicht", zeilen);
- }
-
- private static Arbeitsblatt Spielplan(IReadOnlyList spiele)
- {
- var zeilen = new List> { Kopf("Datum", "Tag", "Uhrzeit", "KW", "Runde", "Heim", "Gast", "Hinweis") };
- zeilen.AddRange(spiele.Select(Spielzeile));
- return new Arbeitsblatt("Spielplan", zeilen, Kopfzeile: 0);
- }
-
- private static Arbeitsblatt Mannschaftsplaene(IReadOnlyList spiele, Staffel staffel)
- {
- var zeilen = new List> { Kopf("Mannschaft", "Datum", "Tag", "Uhrzeit", "H/A", "Gegner", "Heim", "Gast", "Hinweis") };
- foreach (string name in staffel.Mannschaften.Select(m => m.Name))
- {
- zeilen.AddRange(spiele.Where(z => z.Heim == name || z.Gast == name).Select(z => Mannschaftszeile(name, z)));
- }
-
- return new Arbeitsblatt("Mannschaftspläne", zeilen, Kopfzeile: 0);
- }
-
- private static Arbeitsblatt Kosten(IReadOnlyList kennzahlen, Kostentabelle tabelle)
- {
- var kopf = new List { "Mannschaft" };
- kopf.AddRange(tabelle.Spalten.Select(s => s.Text));
- kopf.Add("Gesamt");
- kopf.Add("Meldungen");
- var zeilen = new List> { Kopf(kopf.ToArray()) };
- foreach (Kostenzeile z in tabelle.Zeilen)
- {
- var zellen = new List { Blattzelle.Text(z.Mannschaft.Text) };
- zellen.AddRange(z.Zellen.Select(Feld));
- zellen.Add(Feld(z.Gesamt));
- zellen.Add(Blattzelle.Text(z.Meldungen.Replace(Environment.NewLine, "; ", StringComparison.Ordinal)));
- zeilen.Add(zellen);
- }
-
- var summe = new List { Blattzelle.Text("Summe", fett: true) };
- summe.AddRange(tabelle.Summen.Select(f => Feld(f) with { Fett = true }));
- summe.Add(Feld(tabelle.Gesamtsumme) with { Fett = true });
- zeilen.Add(summe);
- zeilen.Add([]);
- zeilen.AddRange(kennzahlen.Select(k => (IReadOnlyList)[Blattzelle.Text(k.Name, fett: true), Feld(k.Wert)]));
- return new Arbeitsblatt("Kosten", zeilen);
- }
-
- private static List Spielzeile(Terminplanzeile z) =>
- [
- Blattzelle.Datum(z.Zeitpunkt),
- Blattzelle.Text(Wochentag(z.Zeitpunkt)),
- Blattzelle.Uhrzeit(z.Zeitpunkt),
- z.Zeitpunkt is null ? Blattzelle.Leer : Blattzelle.Zahl(z.Kalenderwoche),
- Blattzelle.Zahl(z.Runde),
- Blattzelle.Text(z.Heim),
- Blattzelle.Text(z.Gast),
- Blattzelle.Text(Hinweis(z)),
- ];
-
- private static List Mannschaftszeile(string name, Terminplanzeile z) =>
- [
- Blattzelle.Text(name),
- Blattzelle.Datum(z.Zeitpunkt),
- Blattzelle.Text(Wochentag(z.Zeitpunkt)),
- Blattzelle.Uhrzeit(z.Zeitpunkt),
- Blattzelle.Text(z.Heim == name ? "H" : "A"),
- Blattzelle.Text(z.Heim == name ? z.Gast : z.Heim),
- Blattzelle.Text(z.Heim),
- Blattzelle.Text(z.Gast),
- Blattzelle.Text(Hinweis(z)),
- ];
-
- private static string Hinweis(Terminplanzeile z) => z.Zeitpunkt is null ? Verbinden("nicht terminiert", z.Hinweis) : z.Hinweis;
-
- private static Blattzelle Feld(Kostenfeld feld)
- {
- string text = feld.Markierung.Length > 0 ? feld.Text + " " + feld.Markierung : feld.Text;
- if (feld.Anteil <= 0)
- {
- return Blattzelle.Text(text);
- }
-
- return new Blattzelle(text, Hintergrund: Rgb(Druckbericht.Hinterlegung(feld.Anteil)), Schriftfarbe: Rgb(Druckbericht.Hinterlegungsschrift(feld.Anteil)));
- }
-
- private static List Kopf(params string[] titel) =>
- titel.Select(t => new Blattzelle(t, Fett: true, Hintergrund: Kopfhintergrund)).ToList();
-
- private static List Paar(string name, string wert) => [Blattzelle.Text(name, fett: true), Blattzelle.Text(wert)];
-
- private static string Wochentag(DateTime? zeitpunkt) => zeitpunkt is DateTime t ? t.ToString("ddd", Deutsch) : string.Empty;
-
- private static int Rgb(Farbe farbe) => (farbe.R << 16) | (farbe.G << 8) | farbe.B;
-
- private static string Verbinden(string a, string b) => b.Length == 0 ? a : a + ", " + b;
-}
diff --git a/src/AndiGenerator.Presentation/Zeilenbearbeitung.cs b/src/AndiGenerator.Presentation/Zeilenbearbeitung.cs
new file mode 100644
index 0000000..b198088
--- /dev/null
+++ b/src/AndiGenerator.Presentation/Zeilenbearbeitung.cs
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2017 Andreas Hofmann
+// SPDX-FileCopyrightText: 2026 Peter Buchmann
+// SPDX-License-Identifier: GPL-3.0-only
+
+using AndiGenerator.Engine.Inseln;
+
+namespace AndiGenerator.Presentation;
+
+/// Bearbeitung einer Zeile der Qualitätsansicht.
+/// Ordnet das Kriterium einer anderen Stufe zu.
+/// Verschiebt nach oben; null = schon oben in der Stufe.
+/// Verschiebt nach unten; null = schon unten in der Stufe.
+internal sealed record Zeilenbearbeitung(Action Einstufen, Action? Hoch, Action? Runter);
diff --git a/src/AndiGenerator.UI/Ansichten/DiagrammAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/DiagrammAnsicht.axaml
index 31f07d0..73ce166 100644
--- a/src/AndiGenerator.UI/Ansichten/DiagrammAnsicht.axaml
+++ b/src/AndiGenerator.UI/Ansichten/DiagrammAnsicht.axaml
@@ -44,12 +44,6 @@
-
-
diff --git a/src/AndiGenerator.UI/Ansichten/KostenAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/KostenAnsicht.axaml
index 1ceb67e..3171b05 100644
--- a/src/AndiGenerator.UI/Ansichten/KostenAnsicht.axaml
+++ b/src/AndiGenerator.UI/Ansichten/KostenAnsicht.axaml
@@ -71,12 +71,6 @@
-
-
diff --git a/src/AndiGenerator.UI/Ansichten/MeldungenAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/MeldungenAnsicht.axaml
index 15ab211..19a2a48 100644
--- a/src/AndiGenerator.UI/Ansichten/MeldungenAnsicht.axaml
+++ b/src/AndiGenerator.UI/Ansichten/MeldungenAnsicht.axaml
@@ -14,9 +14,6 @@
-
diff --git a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml
index 0146489..b6185d9 100644
--- a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml
+++ b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml
@@ -4,6 +4,136 @@
xmlns:ui="using:AndiGenerator.UI"
x:Class="AndiGenerator.UI.Ansichten.QualitaetAnsicht"
x:DataType="p:QualitaetAnsichtViewModel">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -14,12 +144,6 @@
-
-
@@ -35,39 +159,73 @@
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs
index 4fc7438..57350f5 100644
--- a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs
+++ b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs
@@ -2,17 +2,116 @@
// SPDX-FileCopyrightText: 2026 Peter Buchmann
// SPDX-License-Identifier: GPL-3.0-only
+using AndiGenerator.Presentation;
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.VisualTree;
namespace AndiGenerator.UI.Ansichten;
-/// Qualitätsansicht: Verstöße je Kriterium nach der Rangfolge A/B/C.
+///
+/// Qualitätsansicht: Verstöße je Kriterium nach der Rangfolge A/B/C. Im Automodus lassen sich Zeilen am Griff ziehen:
+/// Die Zeile hängt dann am Mauszeiger, und die Tabelle zeigt an der Ablagestelle eine Lücke mit der Nummerierung, die beim
+/// Ablegen entstünde (Vorschau im ViewModel).
+///
public partial class QualitaetAnsicht : UserControl
{
+ private bool ziehen;
+ private double griffhoehe;
+
/// Initialisiert die Ansicht.
public QualitaetAnsicht()
{
InitializeComponent();
Mausradzoom.Anmelden(this);
+ Focusable = true;
+ KeyDown += (_, e) =>
+ {
+ if (ziehen && e.Key == Key.Escape)
+ {
+ Beenden(uebernehmen: false, null);
+ e.Handled = true;
+ }
+ };
+ }
+
+ private QualitaetAnsichtViewModel? Modell => DataContext as QualitaetAnsichtViewModel;
+
+ ///
+ /// Druck auf einen Griff (Border der Klasse griff) beginnt das Ziehen seiner Zeile; Klick sonst auf die Zeile
+ /// wählt sie (Verstöße rechts daneben). Knöpfe der Zeile behandeln ihren Klick selbst.
+ ///
+ private void ListeGedrueckt(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ Border? griff = (e.Source as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("griff"));
+ if (griff is { DataContext: Qualitaetszeile zeile } && Modell is { } modell && modell.ZiehenBeginnen(zeile))
+ {
+ ziehen = true;
+ Border? zeilenrand = griff.GetVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("zeile"));
+ griffhoehe = zeilenrand is null ? 0 : e.GetPosition(zeilenrand).Y;
+ Schwebezeile.Width = Liste.Bounds.Width;
+ Mitfuehren(e);
+ e.Pointer.Capture(Liste);
+ Focus();
+ e.Handled = true;
+ return;
+ }
+
+ Border? zeilenrahmen = (e.Source as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("zeile"));
+ if (zeilenrahmen is { DataContext: Qualitaetszeile gewaehlt } && Modell is { } m)
+ {
+ m.Waehlen(gewaehlt);
+ e.Handled = true;
+ }
+ }
+
+ private void ListeBewegt(object? sender, PointerEventArgs e)
+ {
+ if (!ziehen || Modell is not { } modell)
+ {
+ return;
+ }
+
+ Mitfuehren(e);
+
+ // Zeile unter dem Zeiger: obere Hälfte = davor, untere Hälfte = dahinter.
+ foreach (Control behaelter in Liste.GetRealizedContainers())
+ {
+ Point p = e.GetPosition(behaelter);
+ if (p.Y >= 0 && p.Y < behaelter.Bounds.Height && behaelter.DataContext is Qualitaetszeile ziel)
+ {
+ modell.ZiehenUeber(ziel, p.Y < behaelter.Bounds.Height / 2);
+ return;
+ }
+ }
+ }
+
+ /// Hängt die schwebende Zeile an den Mauszeiger (senkrecht beweglich, bündig mit der Tabelle).
+ private void Mitfuehren(PointerEventArgs e)
+ {
+ Canvas.SetLeft(Schwebezeile, Liste.TranslatePoint(default, Schwebeebene)?.X ?? 0);
+ Canvas.SetTop(Schwebezeile, e.GetPosition(Schwebeebene).Y - griffhoehe);
+ }
+
+ private void ListeLosgelassen(object? sender, PointerReleasedEventArgs e) => Beenden(uebernehmen: true, e.Pointer);
+
+ private void ListeVerloren(object? sender, PointerCaptureLostEventArgs e) => Beenden(uebernehmen: false, null);
+
+ private void Beenden(bool uebernehmen, IPointer? zeiger)
+ {
+ if (!ziehen)
+ {
+ return;
+ }
+
+ ziehen = false;
+ zeiger?.Capture(null);
+ Modell?.ZiehenBeenden(uebernehmen);
}
}
diff --git a/src/AndiGenerator.UI/Ansichten/TerminplanAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/TerminplanAnsicht.axaml
index 4061cc4..6c9888c 100644
--- a/src/AndiGenerator.UI/Ansichten/TerminplanAnsicht.axaml
+++ b/src/AndiGenerator.UI/Ansichten/TerminplanAnsicht.axaml
@@ -73,12 +73,6 @@
-
-
diff --git a/src/AndiGenerator.UI/EinrichtungAnsicht.axaml b/src/AndiGenerator.UI/EinrichtungAnsicht.axaml
index e6ae148..7eaed31 100644
--- a/src/AndiGenerator.UI/EinrichtungAnsicht.axaml
+++ b/src/AndiGenerator.UI/EinrichtungAnsicht.axaml
@@ -88,15 +88,9 @@
-
+
-
-
+
@@ -123,7 +117,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -153,21 +163,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/AndiGenerator.UI/HauptfensterView.axaml b/src/AndiGenerator.UI/HauptfensterView.axaml
index d3c55f9..24c5a36 100644
--- a/src/AndiGenerator.UI/HauptfensterView.axaml
+++ b/src/AndiGenerator.UI/HauptfensterView.axaml
@@ -44,6 +44,10 @@
+
+
@@ -53,11 +57,44 @@
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -202,6 +262,43 @@
+
+
+
+
+
+
+
+
+