Kamerabild (gezeichnet, 480 × 360)
noch nicht kalibriert
Aufnahmebedingungen
Verläufe
Oben Füllstand (blau) und Sollwert (rot), unten der Stellgrad der Pumpe. 300 Punkte wie
MAX_POINTS im Programm, ein Punkt je Reglerzyklus (0,5 s). Der Zeitraffer oben beschleunigt nur die
Darstellung — gerechnet wird mit denselben 0,5 s.
Erkennungsmethode
Die Schwelle ist −0,3 · Empfindlichkeit Graustufen je Zeile. Wird die Beleuchtung heruntergedreht, unterschreitet der Gradient sie irgendwann — dann hält die Anzeige den letzten Wert (orange Linie) und löscht ihn nach „Hold“ Bildern. Empfindlichkeit senken hilft.
Nachgebildet: Gauß, Sobel, Nicht-Maximum-Unterdrückung, Hysterese. Gewählt wird die Zeile mit dem größten Anteil an Kantenpunkten (mindestens 6 % der Breite).
Oberste Zeile, die zu mehr als 12 % in der Farbmaske liegt. Hellwerte über 220 gelten als Glanzlicht und zählen nicht mit.
Versuche ohne Warten — und der Vergleich mit der Python-Rechnung
Beide Versuche laufen mit demselben Algorithmus 300 s durch und werden mit
Modell/streckenmodell.py verglichen. Das ist der zweite unabhängige Weg: gleiche Gleichung, andere
Sprache, anderer Zeitgeber.
Probe der Bildauswertung gegen die Python-Fassung
Dasselbe rechnerisch erzeugte Prüfbild (300 × 40
Bildpunkte, Kante bei Zeile 110) läuft hier in JavaScript und in
Modell/bv_referenz.py — dem wörtlich aus dem Programm herausgeschnittenen Block. Die gefundene Zeile
muss gleich sein; die Konfidenz darf um bis zu 0.005 abweichen, weil OpenCV im
Weichzeichner auf ganze Graustufen rundet.
Womit hier gerechnet wird
| Größe | Wert | Herkunft |
|---|---|---|
| Stellverstärkung K_u | 1.8748 mm/% | Sprungantwort 25.06.2026 |
| Zeitkonstante T | 36.69 s | Sprungantwort 25.06.2026 |
| Totzeit T_d | 1.252 s | Sprungantwort 25.06.2026 |
| Grundstand h₀ | 50.16 mm | Sprungantwort 25.06.2026 |
| K_p / K_i | 1 %/mm · 0.05 %/(mm·s) | Voreinstellung des Programms |
| Reglerzyklus | 0.5 s | root.after(500, regulation) |
| Dämpfung ζ · Kennkreisfrequenz ω_n | 0.7751 · 0.05055 rad/s | Kennkreisgleichung |
| Phasenreserve | 70.3° | offener Kreis mit Totzeit |
| unterste von der Kamera erkennbare Höhe | — mm | untere 15 % des ROI werden nicht durchsucht |
| höchster mit offenem Ventil haltbarer Stand | 200.9 mm | h₀ + K_u·100 % − T·q_v (Pumpe am Anschlag) |
| gemessene Stufe des Wandlers | 0.05372 mm | aus den Rohwerten der Messreihe (12 Bit) |
Die Bildauswertung greift — wie im Programm — nicht in die Regelung ein. Geregelt wird mit dem Ultraschallwert; die Kamera ist der zweite, unabhängige Messweg auf dieselbe Größe.
Worum es geht
Ein durchsichtiger Behälter wird von einer Pumpe gefüllt und läuft über einen Rücklauf wieder leer. Ein Ultraschallsensor misst die Füllhöhe; ein PI-Regler auf einem PC stellt die Pumpe. Ein Ablassventil öffnet auf Knopfdruck und erzeugt eine Störung. Parallel dazu blickt eine ESP32-CAM auf denselben Behälter, und das Programm sucht im Bild die Wasserlinie — das ist die optische Füllhöhenerkennung des Titels.
Dieses Blatt beschreibt genau das Programm
Wasserstandsregelung_Bild_6.py, nicht ein gedachtes
Verfahren. Jede Zahl, jede Schwelle und jede Formel ist entweder aus dem
Programm entnommen (mit Angabe der Funktion), aus der Messreihe
Daten/sprungantwort_2026-06-25.csv gerechnet oder in
Modell/streckenmodell.py und
Modell/nachrechnung.py nachvollziehbar hergeleitet. Wo
etwas nicht belegbar ist, steht das ausdrücklich da
(Abschnitt „Was offen ist”); geschätzt wird nichts.
Eine Besonderheit ist gleich vorweg festzuhalten, weil sie das Verständnis der ganzen Anlage prägt:
Die Bildauswertung greift nicht in die Regelung ein. Sie läuft parallel und zeigt ihren Wert an. Geregelt wird ausschließlich mit dem Ultraschallsensor. Im Programm steht das als Kommentar über der Bildverarbeitung: „Ergebnis wird NUR angezeigt, beeinflusst die Regelung NICHT.”
Die Kamera ist damit ein zweiter, unabhängiger Messweg auf dieselbe Größe — genau das, was Grundsatz 1 der Werkstatt verlangt. Sie ist nicht Ersatz für den Sensor, sondern seine Probe.
Der Aufbau und die realen Schnittstellen
Es gibt drei Geräte und zwei Schnittstellen:
- die Füllstandsanlage (Behälter, Pumpe, Ablassventil, Ultraschallsensor, Durchfluss- und Drucksensor, zwei Schwimmerschalter) auf 24-V-Klemmen,
- den EasyPort als Umsetzer zwischen diesen Klemmen und dem PC, über USB als serielle Schnittstelle,
- den PC mit dem Python-Programm (Tkinter für die Oberfläche, OpenCV für das Bild, Matplotlib für die Verläufe),
- die ESP32-CAM, die über WLAN einen MJPEG-Strom liefert.
Die serielle Schnittstelle zum EasyPort
Das Programm öffnet die Schnittstelle mit festen Werten (Funktion
serial.Serial im Kopf):
| Größe | Wert |
|---|---|
| Schnittstelle | COM9 |
| Übertragungsrate | 115200 Bd |
| Rahmen | 8 Datenbits, keine Parität, 1 Stoppbit |
| Wartezeit lesen / schreiben | je 0,5 s |
Der EasyPort spricht ein ASCII-Registerprotokoll,
kein Modbus. Das Programm benutzt genau zwei Befehle
(read_register, write_register):
- Lesen:
D<Register>\rsenden, bis zum Wagenrücklauf lesen, die Antwort hat die Form<Register>=hhhh. Das Programm trennt an=und liest den Wert als Hexadezimalzahl (int(value, 16)). - Schreiben:
M<Register>=hhhh\rsenden; die Antwort wird gelesen und verworfen.
Beide Zugriffe laufen unter einem threading.Lock, weil
zwei Abläufe gleichzeitig auf die Schnittstelle zugreifen (Regelschleife
und Bedienknöpfe). Vor jedem Lesen wird der Empfangspuffer verworfen
(reset_input_buffer) — sonst käme die Antwort eines
früheren Befehls zurück.
Die belegten Register
Das ist die vollständige Liste der Register, die im Programm vorkommen — mehr sind es nicht:
| Register | Richtung | Bedeutung | Umrechnung im Programm |
|---|---|---|---|
EW1.0, Bit 3 |
lesen | Schwimmerschalter unten | fs_unten():
(roh & (1<<3)) != 0 |
EW1.0, Bit 4 |
lesen | Schwimmerschalter oben | fs_oben():
(roh & (1<<4)) != 0 |
EW1.2 |
lesen | Füllhöhe (Ultraschall) | level_mm():
mm |
EW1.4 |
lesen | Durchfluss | durchfluss_volt():
V |
EW1.6 |
lesen | Druck | druck_volt():
V |
AW1.0, Bit 0 |
schreiben | Ablassventil | 1 = offen, 0 = geschlossen |
AW1.0, Bits 2 und 3 |
schreiben | Pumpenfreigabe | pump_set setzt
AW1.0 |= 0x000C |
AW1.2 |
schreiben | Pumpenstellgrad | pump_set:
|
Bemerkenswert an pump_set: die Freigabebits werden
vor jedem Stellwert neu gesetzt, weil das Register
AW1.0 auch das Ventilbit trägt und sonst beim
Ventilschalten verloren ginge. pump_stop schreibt erst den
Stellwert 0 und nimmt danach die Freigabe zurück
(AW1.0 &= 0xFFF3).
Durchfluss, Druck und die beiden Schwimmerschalter werden nur angezeigt. Sie gehen nirgends in die Regelung ein; ihre Umrechnung in physikalische Einheiten (l/min, bar) steht nicht im Programm, darum steht sie auch hier nicht.
Was die Messreihe über den Wandler verrät
Die Formel level_mm() rechnet mit der vollen Spanne 0 …
32760, also mit einem 16-Bit-Wort. Ob der Analogeingang wirklich so fein
auflöst, steht in der Messreihe selbst. Rechnet man die 370
aufgezeichneten Füllhöhen in Rohwerte zurück, so gilt für
alle 370 Werte:
Der größte gemeinsame Teiler aller auftretenden Sprünge ist 8. Der Wandler liefert also Stufen — 12 Bit, nicht 16. Daraus folgt für die Rückführung:
| Größe | Wert |
|---|---|
| Rechenschritt der Formel () | 0,00672 mm |
| tatsächlicher Messschritt () | 0,05372 mm |
| fester Versatz () | 0,03358 mm |
| Stellschritt der Pumpe () | 0,00305 % |
Der feste Versatz von 0,034 mm verschwindet in jeder Kalibrierung und
ist ohne Bedeutung; die Auflösung von 0,054 mm ist die Zahl, mit der zu
rechnen ist. Sie ist um mehr als zwei Größenordnungen feiner als jede
Regelabweichung, die später auftritt — die Quantisierung ist also
nicht die begrenzende Größe. Gerechnet in
Modell/nachrechnung.py, Abschnitt 5.
Nicht belegt: Die Messgenauigkeit des Ultraschallsensors selbst (Linearität, Temperaturgang, Streuung bei bewegter Oberfläche) ist nicht gemessen worden. 0,054 mm ist die Stufe des Wandlers, nicht die Genauigkeit der Messung.
Die Kamera
Die ESP32-CAM liefert einen MJPEG-Strom. Das Programm holt ihn in
einem eigenen Faden (mjpeg_thread):
| Größe | Wert im Programm |
|---|---|
| Bildstrom | http://<IP-der-Kamera>:81/stream |
| Einstellungen | http://<IP-der-Kamera>/control?var=…&val=… |
| Anzeigegröße | 480 × 360 Bildpunkte (CAM_W,
CAM_H) |
| Wiederverbindung | alle 3 s
(RECONNECT_INTERVAL) |
| als offline gilt | 5 s ohne neues Bild
(TIMEOUT_OFFLINE) |
| Bildtakt der Anzeige | 40 ms
(root.after(40, update_kamera)) |
Der Strom wird nicht von einer Bibliothek zerlegt,
sondern von Hand: das Programm sammelt Bytes in einem Puffer und sucht
darin die JPEG-Marken FF D8 (Bildanfang) und
FF D9 (Bildende). Was dazwischen liegt, geht an
cv2.imdecode. Wächst der Puffer über 100 000 Bytes, ohne
dass ein vollständiges Bild darin steht, wird er verworfen — so kann ein
abgerissener Strom die Schleife nicht verstopfen. Jedes
dekodierte Bild geht unmittelbar in bv_verarbeite, also
läuft die Bildauswertung mit der Bildrate der Kamera und nicht mit dem
Reglertakt.
Der Weißabgleich ist über fünf Stufen umschaltbar
(WB_MODI: Auto, Sonnig, Bewölkt, Büro, Wohnraum). „Auto”
schaltet awb=1 und wb_mode=0, jede feste Stufe
schaltet awb=0 und wb_mode=<Nummer>. Die
Anforderungen laufen in einem eigenen Faden, damit die Oberfläche nicht
stehenbleibt, wenn die Kamera nicht antwortet.
Nicht belegt: Brennweite, Bildwinkel, Abstand zum Behälter und Auflösung des Sensors stehen nicht im Programm. Die Umrechnung Bildpunkt → Millimeter wird deshalb nicht aus der Geometrie berechnet, sondern gemessen (Abschnitt „Kalibrierung”).
Die Bildauswertung, Schritt für Schritt
Die Bildauswertung liegt im Programm zwischen den Zeilen 327 und 508.
Diese Zeilen sind wörtlich nach
Modell/bv_referenz.py herausgeschnitten (durch
Modell/erstelle_bv_referenz.py), damit dieselbe Erkennung
ohne Kamera, ohne EasyPort und ohne Oberfläche auf beliebigen Bildern
läuft. Alles, was hier steht, ist an diesem Auszug nachprüfbar.
Der Bildausschnitt (ROI)
Ausgewertet wird nicht das ganze Bild, sondern ein Rechteck
roi = [x1, y1, x2, y2] im Originalbild
(nicht in der 480 × 360-Anzeige). Voreinstellung:
[50, 30, 590, 450]. Der Bediener zieht es mit der Maus über
dem Kamerabild neu auf; maus_release rechnet die
Anzeigekoordinaten mit den Faktoren
und
auf das Originalbild zurück und verlangt mindestens 10 Bildpunkte
Kantenlänge. Bei jeder Änderung wird der Glättungspuffer geleert.
In bv_verarbeite wird das Rechteck auf das Bild
beschnitten; ist der Ausschnitt in einer Richtung schmaler als 10
Bildpunkte, bricht die Auswertung ohne Ergebnis ab.
Methode „Helligkeit” (Voreinstellung)
Das ist die Methode für trübes Wasser in einem durchsichtigen
Behälter: die Luft über dem Wasser ist heller als das
Wasser. Die Wasserlinie ist damit die Zeile mit dem stärksten
Helligkeitsabfall von oben nach unten. Sieben Schritte, in dieser
Reihenfolge (_suche_helligkeit):
- Graustufen:
cv2.cvtColor(ausschnitt, cv2.COLOR_BGR2GRAY), also die OpenCV-Gewichtung . - Senkrechte Glättung:
cv2.GaussianBlur(grau, (1, 31), 0). Die Kerngröße ist (Breite = 1, Höhe = 31) — es wird nur in Spaltenrichtung geglättet, über 31 Zeilen. Das unterdrückt Reflexe und Rauschen und verschmiert zugleich die Kante über rund 31 Zeilen; das ist der Preis der Robustheit und die Ursache der Empfindlichkeitsschwelle in Schritt 6. - Zeilenmittel:
grau.mean(axis=1)ergibt das Helligkeitsprofil über die Zeilen des Ausschnitts. - Gleitendes Mittel über 5 Zeilen:
np.convolve(p, np.ones(5)/5, mode='same'). - Ableitung:
np.gradient(p)— der zentrale Differenzenquotient, also im Inneren und einseitig an den Rändern. - Suche: durchsucht wird nur der obere Teil,
such_bis = int(n·0,85); der untere Rand ist Behälterboden und Tisch. Der stärkste Abfall ist . Ist dieser Wert größer als , gilt kein Übergang als gefunden und die Methode liefert nichts. Mit der Voreinstellung ist die Schranke Graustufen je Zeile. Die gesuchte Zeile istnp.argmin. Anmerkung: Der Kommentar im Programm spricht von „oberen 80 %“, der Code rechnet mit 0,85. Es gilt der Code: 85 %. Folge für den Aufbau: Was im untersten Sechstel des Bildausschnitts liegt, findet diese Methode nicht. Zieht der Bediener den ROI eng um den Glasinhalt — wie die Bedienhilfe im Programm es rät —, so ist das unterste Sechstel des Füllbereichs für die Kamera unsichtbar. In der Browser-Seite ist diese Grenze aus der Zeichnung gerechnet und im Versuch bestätigt: sie liegt dort bei 80,4 mm; 12 mm darunter findet die Methode nichts, 12 mm darüber liest sie 93,0 mm. - Konfidenz: mit (ist , gilt das Bild als flau und es gibt kein Ergebnis) Die Konfidenz vergleicht also den Abfall je Zeile mit 30 % des Helligkeitsumfangs im ganzen Ausschnitt.
Was Schritt 2 für Schritt 6 bedeutet, lässt sich geschlossen angeben und von Hand nachrechnen: ein Sprung um Graustufen wird von einem Gauß mit Zeilen zu einer Flanke mit dem größten Betrag Damit die Voreinstellung anspricht (), braucht es einen Helligkeitsunterschied von rund 117 Graustufen zwischen Luft und Wasser. Das ist keine Theorie: das Rechenbild des Bildes oben ist genau daran geeicht. Mit einem Wasser-Grauwert von 88 (Unterschied 126) erreicht der Gradient und die Methode findet nichts; mit dem gezeichneten Grauwert 62 (Unterschied 152) erreicht er und sie findet die Linie. Wer in der Anlage trübes Wasser bei schwacher Beleuchtung hat, muss die Empfindlichkeit heruntersetzen — der Schieber reicht von 5 bis 80.
Methode „Canny”
_suche_wasserlinie_canny sucht waagerecht
durchgehende Kanten:
- Kerngröße ungerade machen (
blur_kern, Voreinstellung 5; gerade Werte werden um 1 erhöht), - Graustufen,
cv2.GaussianBlur(grau, (k, k), 0)— hier quadratisch, also in beide Richtungen, cv2.Canny(grau, canny_t1, canny_t2)mit den Voreinstellungen 30 und 80,- je Zeile den Anteil der Kantenpunkte bilden: mit der ROI-Breite ,
- Kandidaten sind die Zeilen mit ; gewählt wird die Zeile mit dem größten Anteil,
- Konfidenz — eine Zeile, die zu 40 % aus Kantenpunkten besteht, gilt als voll vertrauenswürdig.
Schieber: T1 von 5 bis 150, T2 von 10 bis 300, Weichzeichnung von 1 bis 21.
Methode „Farbfilter (HSV)”
_suche_wasserlinie_farbe sucht die Oberkante
einer Farbmaske:
cv2.cvtColor(…, cv2.COLOR_BGR2HSV); OpenCV zählt den Farbton von 0 bis 179,cv2.inRange(hsv, (h_lo, s_lo, v_lo), (h_hi, 255, 220))mit den Voreinstellungen (rot-braun bis orange), , — die Obergrenze 220 für den Hellwert schließt Glanzlichter aus,- Lücken schließen:
MORPH_CLOSEmit einem Rechteck 5 × 3 (breiter als hoch, weil die Wasserfläche waagerecht zusammenhängt), - je Zeile die Maskensumme; als besetzt gilt eine Zeile ab , also ab 12 % der Breite,
- genommen wird die oberste besetzte Zeile
(
kandidaten.min()) — anders als bei Canny, wo die stärkste genommen wird, - Konfidenz : eine Zeile, die zur Hälfte in der Maske liegt, gilt als voll vertrauenswürdig.
Was mit dem Rohergebnis geschieht
bv_verarbeite nimmt Zeile und Konfidenz der gewählten
Methode entgegen und macht daraus den angezeigten Wert:
- Annahme oder Ablehnung. Liegt kein Ergebnis vor
oder ist die Konfidenz kleiner als
konf_schwelle(Voreinstellung 0,05, Schieber 0,01 bis 0,30), so wird der Wert gehalten:bv_hold_countzählt hoch, der letzte gültige Wert bleibt stehen. Erst nachHOLD_MAX = 10gehaltenen Bildern (Schieber 1 bis 60) wird die Anzeige gelöscht. Ein gültiges Ergebnis setzt den Zähler auf 0 zurück. - Zurück ins Originalbild:
beste_orig = y1 + beste_roi. - Mittelung über 12 Bilder: ein Ringpuffer der Länge
GLAETT_N = 12; der angezeigte Bildpunkt istint(np.mean(puffer))— also der abgeschnittene, nicht der gerundete Mittelwert. Das ist ein systematischer Versatz von bis zu einem Bildpunkt nach oben. - Umrechnung in Millimeter (Abschnitt „Kalibrierung”).
Die Farbe der eingeblendeten Linie sagt, in welchem dieser Zustände die Auswertung ist: Cyan bei Konfidenz über 0,15, Blau darunter, Orange, solange gehalten wird. Das ist eine Bedienregel, keine Verzierung — eine orange Linie heißt: die Kamera sieht gerade nichts, der Wert ist alt.
Kalibrierung: Bildpunkt → Millimeter
Es gibt keine Umrechnung aus der Geometrie. Die
Umrechnung ist eine Gerade durch zwei gemessene Punkte
(pixel_zu_mm):
Der Bediener bringt den Füllstand auf einen bekannten Wert, trägt ihn
in das Feld ein und drückt „Punkt setzen”; das Programm merkt sich das
Paar (erkannter Bildpunkt, Millimeterwert). Beim zweiten Punkt gilt die
Kalibrierung. Ein dritter Punkt verdrängt den ersten
(kalib_punkte.pop(0)), es sind also immer die
beiden letzten. Ohne zwei Punkte zeigt das Programm „—
(nicht kalibriert)” an; die Linie wird trotzdem gezeichnet.
Die Gerade unterstellt, dass die Kamera senkrecht auf den Behälter blickt und der Behälter über die Messhöhe denselben Querschnitt hat. Beides ist nicht gemessen — siehe „Was offen ist”.
Der Regler
Der Regler ist ein PI-Regler mit Anti-Windup,
geschrieben in regulation(). Er läuft in der
Tkinter-Ereignisschleife, nicht in einem eigenen Faden.
Takt und Zeitschritt
root.after(500, regulation) am Ende jedes Durchlaufs:
der Nennzyklus ist 0,5 s. Der tatsächlich verwendete
Zeitschritt wird aber gemessen und begrenzt:
dt = now - last_time
dt = max(0.001, min(dt, 2.0))
Das ist wichtig und keine Kosmetik: root.after
garantiert nur ein Mindestintervall. Bleibt die Oberfläche hängen
(Zeichnen der Verläufe, Kamerabild), so wäre der Zeitschritt größer; die
Klammerung auf höchstens 2 s verhindert, dass ein einziger langer
Durchlauf das Integral aufbläht. Der erste Durchlauf setzt nur
last_time und rechnet nicht.
Die Gleichung
Mit dem Sollwert
(Feld „Sollwert”), dem Istwert $y = $ level_mm() und den
Beiwerten
und
aus den Eingabefeldern:
Das ist die Rechteckregel von links (Euler vorwärts) für das Integral — von Hand nachrechenbar, und genau so steht es im Programm.
Voreinstellungen der Oberfläche: mm, %/mm, %/(mm·s).
Anti-Windup und Sollwertsprung
Die Stellgröße ist auf 0 … 100 % begrenzt. Bei Anschlag wird das Integral zurückgerechnet, so dass es genau den Wert hat, der zur Grenze führt:
if output > 100.0: integral -= (output - 100.0) / ki ; output = 100.0
elif output < 0.0: integral -= output / ki ; output = 0.0
Man rechnet das in einer Zeile nach: nach der Korrektur ist bzw. . Das Integral „lädt sich” also nicht auf, solange die Pumpe am Anschlag steht — und der Regler löst sich sofort vom Anschlag, sobald die Regelabweichung das Vorzeichen wechselt. (Bei setzt das Programm einen Ersatzwert ein, um die Division zu vermeiden.)
Zweite Vorkehrung: ändert der Bediener den Sollwert um mehr als 1,0 mm, wird das Integral auf 0 gesetzt. Der Regler beginnt den Sprung also aus dem Stand, nicht mit dem alten Ladezustand.
Der Knopf „Regler EIN/AUS” schaltet die Rechnung; beim Einschalten werden Integral, Zeitmarke und letzter Sollwert zurückgesetzt, beim Ausschalten wird die Pumpe angehalten. „Integral zurücksetzen” nullt nur das Integral, „Pumpe STOP” schreibt Stellwert 0 und nimmt die Freigabe.
Was gemessen und angezeigt wird
Jeder Durchlauf liest zusätzlich Durchfluss, Druck und die beiden
Schwimmerschalter und schreibt Istwert, Sollwert und Stellgröße in drei
Ringpuffer mit 300 Punkten (MAX_POINTS);
bei 0,5 s Zyklus sind das 150 s Verlauf. Die beiden Diagramme werden in
jedem Zyklus vollständig neu gezeichnet. Eine Ausnahme in
regulation() bricht den Zyklus nicht ab: sie wird im
Fehlerfeld angezeigt, und der nächste Durchlauf wird trotzdem
angemeldet.
Das Streckenmodell des Behälters
Die Bilanz
Für einen Behälter mit dem lichten Querschnitt gilt die Mengenbilanz
Der Zufluss ist dem Stellgrad verhältnisgleich, . Für den Abfluss über den Rücklauf gibt es zwei Ansätze, die sich an der Messung unterscheiden lassen:
(a) verhältnisgleich zur Höhe (laminarer Widerstand, enge Drossel): . Damit
also ein PT1-Glied mit und .
(b) nach Torricelli (freier Ausfluss aus einer Öffnung): , also
Dazu kommt in beiden Fällen eine Totzeit : die Pumpe fördert durch eine Leitung, und der Ultraschallsensor liefert seinen Wert nicht sofort.
Die Messung
Am 25.06.2026 wurde die Pumpe bei von 0 auf 100 % gestellt und der Füllstand aufgezeichnet, bis 200 mm erreicht waren:
| Größe | Wert |
|---|---|
| Punkte | 370 |
| mittlerer Abstand | 0,12 s |
| Dauer | 44,34 s |
| Anfang | 50,25 mm |
| Ende | 209,59 mm |
| Rauschen (Standardabweichung der ersten 20 Punkte) | 0,062 mm |
Zwei Dinge sind an dieser Messung festzuhalten, bevor irgendetwas angepasst wird:
- Sie ist kürzer als eine Zeitkonstante mal 1,3. Bei s deckt sie 1,21 ab; der Endwert wird also hochgerechnet, nicht gemessen. Die Unsicherheit dieser Hochrechnung ist beziffert (unten, ).
- Der letzte Punkt ist ein Ausreißer. Er springt um 27,13 mm gegenüber seinem Vorgänger, bei 0,12 s Abstand — das kann kein Füllstand sein. Er stammt aus dem Abbruch der Aufzeichnung. Lässt man ihn weg, so ergibt die Anpassung mm statt 187,48 mm und s statt 36,69 s. Beide Änderungen liegen innerhalb einer Standardabweichung ( mm, s). Der Ausreißer wird darum nicht entfernt, sondern beziffert.
Die Anpassung
Modell/streckenmodell.py passt drei Modelle an dieselbe
Messreihe an (kleinste Fehlerquadrate,
scipy.optimize.curve_fit bzw.
least_squares):
| Modell | Parameter | Restfehler (RMS) |
|---|---|---|
| PT1 mit Totzeit | mm, s, s, mm | 3,42 mm |
| Behälter nach Torricelli | mm/s, , s, mm | 3,90 mm |
| reiner Integrator (kein Abfluss) | mm/s, s | 6,55 mm |
Standardabweichungen der PT1-Anpassung: mm, s, s, mm.
Drei Schlüsse:
- Der reine Integrator scheidet aus: sein Restfehler ist fast doppelt so groß, und seine Totzeit kommt mit s negativ heraus — die Anpassung müsste die Antwort vor dem Sprung beginnen lassen. Es gibt also messbar einen Abfluss.
- PT1 und Torricelli liegen mit 3,42 mm und 3,90 mm dicht beieinander. Die Messung reicht nicht aus, um zwischen ihnen zu entscheiden — dazu müsste sie bis in die Sättigung laufen, wo sich die beiden Kurvenformen trennen. Weitergerechnet wird mit PT1, weil es den kleineren Restfehler hat und weil es geschlossen im Laplace-Bereich behandelt werden kann.
- Der Restfehler von 3,42 mm ist rund 55-mal so groß wie das Rauschen des Sensors (0,062 mm). Er ist also kein Messrauschen, sondern Modellfehler: die wirkliche Strecke ist nicht genau ein PT1. Das ist die ehrliche Genauigkeitsangabe des Modells.
Die Kennwerte des Behälters
Aus der PT1-Anpassung folgen unmittelbar:
| Größe | Formel | Wert |
|---|---|---|
| Stellverstärkung | 1,8748 mm je % | |
| Zeitkonstante | 36,69 s | |
| Totzeit | 1,252 s | |
| Abflussbeiwert | 0,02726 s | |
| Zuflussbeiwert | 0,05110 mm/(s·%) | |
| Anfangssteigung bei 100 % | 5,11 mm/s | |
| Beharrungshöhe bei 100 % | 237,6 mm |
Die Anfangssteigung ist die Probe von Hand: unmittelbar nach dem Sprung ist noch kein Abfluss aufgebaut, also ist mm/s. Im Bild der Sprungantwort ist das die Tangente durch den Knick bei .
Nicht belegbar: Der lichte Querschnitt
ist nicht gemessen. Aus der Sprungantwort lassen sich nur die
Verhältnisse
und
bestimmen;
,
und
einzeln erst, wenn der Durchfluss in l/min kalibriert vorliegt. Der
Durchflusssensor an EW1.4 liefert dazu eine Spannung, deren
Kennlinie im Programm nicht steht. Deshalb steht in diesem Blatt die
Füllhöhe in Millimetern und nie ein Volumenstrom in Litern.
Der geschlossene Kreis
Übertragungsfunktionen
Strecke und Regler:
Offener Kreis und Führungsübertragungsfunktion (zunächst ohne Totzeit):
Mit den Zahlen mm/%, s, %/mm, %/(mm·s):
Das ist die Kennkreisgleichung. Alle drei Beiwerte sind auf einer Zeile nachzurechnen: und .
Kennwerte und Stabilität
| Größe | Formel | Wert |
|---|---|---|
| Kennkreisfrequenz | 0,05055 rad/s | |
| Dämpfung | 0,775 | |
| Abklingrate | 0,03918 s | |
| gedämpfte Kreisfrequenz | 0,03194 rad/s | |
| Pole | ||
| Schwingungsdauer | 196,7 s | |
| Durchtrittsfrequenz | 0,06045 rad/s | |
| Phasenreserve | 70,3° | |
| Amplitudenreserve | bei | 24,3 |
Beide Pole liegen links der imaginären Achse: der Kreis ist stabil. Die Dämpfung 0,775 liegt nahe am üblichen Entwurfswert ; die Phasenreserve von 70° ist reichlich.
Die Totzeit ist in diesen Zahlen noch nicht enthalten. Setzt man sie als Padé-Näherung erster Ordnung an, , so wird die Kennkreisgleichung kubisch, und ihre Wurzeln sind
Alle drei liegen links. Die Totzeit verschiebt das dominierende Polpaar nur geringfügig — das war zu erwarten: s ist gegen die Schwingungsdauer von 197 s verschwindend. Auch die Abtastung mit 0,5 s ist gegenüber dieser Schwingungsdauer ohne Bedeutung (Verhältnis 1 : 394), weshalb die zeitkontinuierliche Rechnung zulässig ist.
Störübertragungsfunktion
Das geöffnete Ablassventil wirkt als zusätzlicher Abfluss (in mm/s an der Füllhöhe gemessen). In der Bilanz steht er neben dem Zufluss:
Mit folgt
Für geht das gegen null: eine dauerhafte Störung wird vollständig ausgeregelt. Das ist die Wirkung des I-Anteils und braucht keinen Zahlenwert. Was einen Zahlenwert braucht, ist der Einbruch unterwegs (nächster Abschnitt).
Zwei unabhängige Wege zu denselben Kennwerten
Das ist Grundsatz 1 der Werkstatt, hier ausgeführt:
- Weg A — Schritt für Schritt
(
Modell/streckenmodell.py, Funktionsimuliere): der Kreis wird mit genau dem Algorithmus des Programms gerechnet — Zyklus 0,5 s, Totzeit über einen Verzögerungs puffer, Anti-Windup, 16-Bit-Quantisierung von Messwert und Stellgröße, Strecke mit dem Euler-Verfahren bei 0,1 s. - Weg B — geschlossen
(
Modell/nachrechnung.py): Laplace, ohne Abtastung, ohne Totzeit, ohne Stellgrößenbegrenzung. Die Formeln stehen unten und sind auf Papier nachvollziehbar.
Arbeitspunkt bei 150 mm. Im Beharrungszustand fließt so viel zu wie ab, also :
Weg A liefert 53,254 %. Unterschied: 0,000 Prozentpunkte.
Sollsprung 150 → 200 mm. Weg B: aus und mit folgt
Das Maximum liegt bei s und beträgt 106,59 % — also 3,29 mm Überschwingen bei 50 mm Sprunghöhe. Weg A liefert 2,62 mm bei rund 60 s. Der Unterschied von 0,67 mm hat einen Namen: die Stellgrößenbegrenzung. Weg A fährt die Pumpe zu Beginn in den Anschlag bei 100 % (im Bild gut zu sehen), Weg B kennt keinen Anschlag und treibt darum weiter. Die bleibende Abweichung ist auf beiden Wegen null (Weg A: 0,004 mm, das ist die Quantisierung); die Einschwingzeit in ein 2-mm-Band beträgt 81 s.
Störsprung, Ablassventil mm/s. Weg B: mit folgt , größter Betrag bei , also bei s mit 8,55 mm. Weg A liefert 9,30 mm Einbruch und regelt in 60 s aus. Der Unterschied von 0,75 mm (8 %) ist die Wirkung von Totzeit (1,25 s) und Abtastung (0,5 s): der Regler greift später ein.
Beide Unterschiede haben also eine benennbare Ursache, und beide Wege bestätigen einander in Größenordnung, Vorzeichen und Zeitpunkt. Was sie nicht ersetzen, ist die Messung am geschlossenen Kreis: die liegt nicht vor (siehe „Was offen ist”).
Bedienung
Das Programm am PC
- EasyPort anschließen, Anlage einschalten, Kamera mit Strom versorgen.
Wasserstandsregelung_Bild_6.pystarten. Links erscheinen Reglerfelder, Messwerte und die beiden Verläufe; rechts das Kamerabild mit seinen Bedienfeldern.- Sollwert, Kp, Ki eintragen (Voreinstellung 150 mm, 1,0, 0,05).
- „Regler EIN” drücken. Der Knopf zeigt danach den Zustand an. „Regler AUS” hält die Pumpe an.
- **„Ventil ÖFFNEN (Störung)“** öffnet das Ablassventil; der Knopf wird rot und meldet „STÖRUNG AKTIV”. Erneutes Drücken schließt es.
- „Integral zurücksetzen” nullt den I-Anteil, „Pumpe STOP” hält die Pumpe unabhängig vom Regler an.
Die Kamera einstellen
- Der ROI wird mit gedrückter Maustaste über dem Kamerabild aufgezogen (grüner Rahmen). Er soll den gesamten Glasinhalt umfassen — Luft oben, Wasser unten —, aber keinen Tisch.
- Die Methode wählen: „Helligkeit” für trübes Wasser (Voreinstellung), „Canny” für eine deutliche Kante, „Farbfilter (HSV)” für gefärbtes Wasser. Nur die Schieber der gewählten Methode sind sichtbar.
- Die Empfindlichkeit so weit senken, bis die Linie stabil steht; die Konfidenzschwelle niedriger machen heißt stabiler, aber auch leichtgläubiger; Hold bestimmt, wie viele Bilder lang ein alter Wert stehenbleibt.
- Weißabgleich (Knopf „WB”) durchschalten, wenn sich die Farben mit der Beleuchtung ändern.
Kalibrieren
- Füllstand auf einen bekannten Wert bringen (am Ultraschallwert ablesen), diesen Wert in „Füllstand jetzt [mm]” eintragen, „Punkt setzen”.
- Füllstand auf einen deutlich anderen Wert bringen, eintragen, „Punkt setzen”.
- Die Zeile darunter meldet „Kalibriert” (im Programm mit einem Haken davor) und beide Punkte. Ab jetzt zeigt die Kamera Millimeter.
- „Löschen” verwirft die Kalibrierung.
Je weiter die beiden Punkte auseinanderliegen, desto kleiner wirkt sich ein Fehler in der Erkennung auf die Steigung der Geraden aus — man nehme sie nahe an den Rändern des Arbeitsbereichs.
Aufzeichnen
„Screenshot” legt ein JPEG mit eingezeichneter Linie im Ordner
ESP32_Aufnahmen ab, „Video” schreibt eine AVI-Datei (XVID,
24 Bilder/s, 480 × 360) mit allen Einblendungen, bis erneut gedrückt
wird.
Die Seite im Browser
Zum Manuskript gehört eine eigenständige HTML-Seite,
Seite/Wasserstand_1.0.html, erzeugt von
Seite/erstelle_wasserstand_seite.py. Sie läuft
offline, ohne Anlage, ohne Kamera und ohne Netzzugang,
und enthält:
- einen gezeichneten Behälter als Nachbildung des Kamerabildes (480 × 360), mit Schiebern für Beleuchtung, Rauschen und Spiegelung;
- dieselbe Bildauswertung, Zeile für Zeile aus dem Python-Programm nach JavaScript übertragen (Graustufen mit derselben Gewichtung, Gauß (1 × 31), Zeilenmittel, Mittel über 5, Gradient, Suche im oberen 85-%-Bereich, Schwelle Empfindlichkeit, Konfidenz, Halten, Mittel über 12, Zweipunktkalibrierung);
- denselben Regler (PI mit Anti-Windup, Zyklus 0,5 s, Integral-Reset bei Sollsprung über 1 mm);
- dieselbe Strecke ( mit den gemessenen Kennwerten);
- Schieber für den Sollwert, einen Knopf für die Störung (Ablassventil) und die laufenden Verläufe und ;
- dieses Manuskript als Reiter „Dokumentation”.
Der erste Bildschirm ist bedienbar: Behälter, Schieber und Knöpfe stehen oben, der Text dahinter.
Die Seite ist gleichzeitig der dritte Rechenweg: sie
führt den Kreis in einer anderen Sprache und mit einem anderen Zeitgeber
aus. Ihr Prüfprogramm Seite/pruefe_seite.mjs fährt sie in
einem echten Chromium, misst Erkennungsfehler, Überschwingen, bleibende
Abweichung und Störausregelung und vergleicht sie mit den Schranken in
Seite/PRUEFPLAN_Seite.md. Was dabei herauskommt (18
Kriterien, keine Beanstandung):
| Größe | Seite (JavaScript) | Python | Schranke |
|---|---|---|---|
| Bildauswertung am Prüfbild: gefundene Zeile | 109 | 109 | gleich |
| dieselbe, Konfidenz | 0,2092 | 0,2077 | Unterschied ≤ 0,005 |
| Erkennung gegen den gezeichneten Stand (100 … 255 mm) | größte Abweichung 0,78 mm | — | ≤ 3,0 mm |
| Sollsprung 150 → 200 mm: Überschwingen | 2,62 mm | 2,62 mm | Unterschied ≤ 0,2 |
| dieselbe, bleibende Abweichung | 0,002 mm | 0,004 mm | ≤ 0,1 mm |
| dieselbe, Einschwingzeit (±2 mm) | 81 s | 81 s | Unterschied ≤ 0,2 |
| Störung 1 mm/s: größter Einbruch | 9,3 mm | 9,3 mm | Unterschied ≤ 0,2 |
| dieselbe, ausgeregelt in | 60 s | 60 s | Unterschied ≤ 0,2 |
Der Unterschied in der Konfidenz von 0,0014 hat eine benennbare Ursache: OpenCV rundet im Weichzeichner jeden Bildpunkt auf ganze Graustufen, die Seite mittelt zuerst über die Zeile und glättet dann (beides linear, also vertauschbar) und rundet dabei nicht. Die bleibende Abweichung von 0,002 gegen 0,004 mm ist die Quantisierung des Sensorwerts, deren Stufe 0,0067 mm beträgt.
Was offen ist
Was hier nicht steht, steht nicht, weil es nicht belegt ist. Diese Liste gehört zum Ergebnis:
- Es gibt keine Messung am geschlossenen Kreis. Vorliegend ist allein die Sprungantwort der Strecke vom 25.06.2026. Alle Aussagen über Überschwingen, Einschwingzeit und Störausregelung sind gerechnet, nicht gemessen. Zwei unabhängige Rechenwege ersetzen keine dritte Messung.
- Der Abfluss des Ablassventils ist nicht gemessen. Der Wert mm/s ist eine gewählte Größe für die Referenzsimulation, keine Eigenschaft der Anlage. Solange er nicht gemessen ist, sagt der Einbruch von 9,3 mm nichts über den wirklichen Vorgang.
- Der lichte Querschnitt des Behälters ist nicht gemessen, ebenso wenig die Pumpenkennlinie. Darum stehen hier nur die Verhältnisse und und keine Volumenströme.
- Die Kennlinien von Durchfluss- und Drucksensor stehen nicht im Programm; die beiden Werte werden als Spannung angezeigt und sonst nicht verwendet.
- Die Kameraoptik ist nicht vermessen (Abstand, Bildwinkel, Blickrichtung, Verzeichnung). Die Gerade Bildpunkt → Millimeter ist eine Annahme, die durch die Zweipunktkalibrierung nur an zwei Stellen gestützt wird. Ob sie dazwischen gilt, ist nicht geprüft.
- PT1 und Torricelli sind an dieser Messung nicht unterscheidbar (3,42 mm gegen 3,90 mm Restfehler). Zur Entscheidung bräuchte es eine Sprungantwort bis in die Sättigung.
- Der Endwert der Strecke ist hochgerechnet, nicht gemessen: die Messung deckt 1,21 Zeitkonstanten ab. mm.
- Die Genauigkeit des Ultraschallsensors ist nicht bestimmt. Bekannt ist nur die Stufe des Wandlers (0,054 mm) und das Rauschen im Stillstand (0,062 mm).
- Vom Aufbau liegt kein Lichtbild vor. Die Datei
Wasserstand.jpgzeigt ein Bildschirmfoto der Programmoberfläche mit noch schwarzem Kamerafeld, nicht die Anlage. Sie ist als solches beschriftet und wird nicht als Aufbaubild ausgegeben. - Die Bildauswertung ist nicht an echten Kamerabildern vermessen. Alle Zahlen zur Erkennung stammen von gezeichneten Rechenbildern. Damit ist gezeigt, dass das Verfahren rechnet, was es rechnen soll — nicht, wie genau es an dieser Anlage arbeitet.
Anhang: die Dateien
| Datei | Inhalt |
|---|---|
Wasserstandsregelung_Bild_6.py |
das Programm am PC: EasyPort, PI-Regler, Bildauswertung, Oberfläche |
Daten/sprungantwort_2026-06-25.csv |
die Messreihe: Zeit in s, Füllhöhe in mm, 370 Punkte |
Modell/streckenmodell.py |
Anpassung der drei Modelle, Kreisrechnung,
Schritt-für-Schritt-Simulation → streckenmodell.json |
Modell/nachrechnung.py |
der zweite, geschlossene Weg →
nachrechnung.json |
Modell/bv_referenz.py |
die Bildauswertung, wörtlich aus dem Programm herausgeschnitten |
Modell/erstelle_bv_referenz.py |
schneidet sie heraus (nicht von Hand ändern) |
Bilder/erstelle_bilder.py |
erzeugt die drei gezeichneten Bilder dieses Blattes |
Seite/erstelle_wasserstand_seite.py |
erzeugt die HTML-Seite (Fassung aus
Seite/VERSION) |
Seite/pruefe_seite.mjs |
prüft die Seite im echten Browser →
pruefe_seite.json |
Seite/PRUEFPLAN_Seite.md |
der Prüfplan mit den Schranken |
Alle Bilder liegen in Bilder/ und werden von den
genannten Programmen erzeugt; von Hand ist keines gezeichnet.