Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

+671
View File
@@ -0,0 +1,671 @@
"""Abräumen auf der Kobra 3: Abkühlen und mit dem Extruder nach vorn schieben."""
from __future__ import annotations
from grotti.errors import GrottiError
from grotti.printers import family_from_name
# Grenzen gegen Tippfehler (Wischpad ~Y 260, Achse max 260 — Ansetzen knapper davor)
LIMITS = {
"x_min": 0.0,
"x_max": 250.0,
"y_min": 0.0,
"y_max": 250.0,
"z_push_min": 0.6,
"z_push_max": 30.0,
"z_travel_min": 1.0,
"z_travel_max": 80.0,
"speed_min": 5.0,
"speed_max": 80.0,
"bed_max_lo": 15.0,
"bed_max_hi": 80.0,
"nozzle_max_lo": 20.0,
"nozzle_max_hi": 120.0,
"fan_lo": 0,
"fan_hi": 100,
"timeout_lo": 1,
"timeout_hi": 120,
"max_delta_lo": 0.05,
"max_delta_hi": 5.0,
"check_timeout_lo": 1,
"check_timeout_hi": 15,
}
DEFAULT_COOL = {
"type": "cool",
"bed_max": 30.0,
"nozzle_max": 45.0,
"fan_percent": 100,
"timeout_min": 25,
}
DEFAULT_SWEEP = {
"type": "sweep",
"z_push": 2.0,
"z_travel": 20.0,
"x_from": 25.0,
"x_to": 225.0,
"x_step": 20.0,
# möglichst weit hinten, bevor Z sinkt — Present war 220, Teile oft davor
"y_from": 250.0,
"y_to": 5.0,
"speed": 25.0,
}
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = {
"type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen.
"max_delta_mm": 3.0,
"timeout_min": 2,
"x": 125.0,
"y": 125.0,
"ref_x": 25.0,
"ref_y": 125.0,
"z_clear": 20.0,
}
def _num(value, default, lo, hi):
try:
n = float(value if value is not None else default)
except (TypeError, ValueError):
n = float(default)
return max(lo, min(hi, n))
def _int(value, default, lo, hi):
return int(round(_num(value, default, lo, hi)))
def require_kobra_3(printer):
family = (printer or {}).get("family") or family_from_name((printer or {}).get("name") or "")
if family != "kobra_3":
raise GrottiError("Abräumen gibt es nur auf der Kobra 3.", "familie")
return family
def normalize_cool(step=None):
raw = step if isinstance(step, dict) else {}
return {
"type": "cool",
"bed_max": _num(raw.get("bed_max"), DEFAULT_COOL["bed_max"], LIMITS["bed_max_lo"], LIMITS["bed_max_hi"]),
"nozzle_max": _num(
raw.get("nozzle_max"), DEFAULT_COOL["nozzle_max"], LIMITS["nozzle_max_lo"], LIMITS["nozzle_max_hi"]
),
"fan_percent": _int(raw.get("fan_percent"), DEFAULT_COOL["fan_percent"], LIMITS["fan_lo"], LIMITS["fan_hi"]),
"timeout_min": _int(
raw.get("timeout_min"), DEFAULT_COOL["timeout_min"], LIMITS["timeout_lo"], LIMITS["timeout_hi"]
),
}
def normalize_sweep(step=None):
raw = step if isinstance(step, dict) else {}
z_push = _num(raw.get("z_push"), DEFAULT_SWEEP["z_push"], LIMITS["z_push_min"], LIMITS["z_push_max"])
z_travel = _num(raw.get("z_travel"), DEFAULT_SWEEP["z_travel"], LIMITS["z_travel_min"], LIMITS["z_travel_max"])
if z_travel < z_push + 1.0:
z_travel = min(LIMITS["z_travel_max"], z_push + 1.0)
x_from = _num(raw.get("x_from"), DEFAULT_SWEEP["x_from"], LIMITS["x_min"], LIMITS["x_max"])
x_to = _num(raw.get("x_to"), DEFAULT_SWEEP["x_to"], LIMITS["x_min"], LIMITS["x_max"])
x_step = abs(_num(raw.get("x_step"), DEFAULT_SWEEP["x_step"], 1.0, 100.0))
y_from = _num(raw.get("y_from"), DEFAULT_SWEEP["y_from"], LIMITS["y_min"], LIMITS["y_max"])
y_to = _num(raw.get("y_to"), DEFAULT_SWEEP["y_to"], LIMITS["y_min"], LIMITS["y_max"])
if y_from <= y_to:
raise GrottiError("Schieben: Y hinten (y_from) muss größer sein als Y vorn (y_to).", "bahn")
return {
"type": "sweep",
"z_push": z_push,
"z_travel": z_travel,
"x_from": x_from,
"x_to": x_to,
"x_step": x_step,
"y_from": y_from,
"y_to": y_to,
"speed": _num(raw.get("speed"), DEFAULT_SWEEP["speed"], LIMITS["speed_min"], LIMITS["speed_max"]),
}
def normalize_print(step=None, fallback=None):
raw = step if isinstance(step, dict) else {}
fb = fallback if isinstance(fallback, dict) else {}
try:
file_id = int(raw.get("file_id") if raw.get("file_id") is not None else fb.get("file_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Druck-Schritt braucht eine Datei.", "datei") from exc
spool_raw = raw.get("spool_id") if "spool_id" in raw else fb.get("spool_id")
try:
spool_id = int(spool_raw) if spool_raw not in (None, "") else None
except (TypeError, ValueError):
spool_id = None
ace = raw.get("ace_map") if isinstance(raw.get("ace_map"), list) else fb.get("ace_map")
if not isinstance(ace, list):
ace = []
return {
"type": "print",
"file_id": file_id,
"spool_id": spool_id,
"ace_map": ace,
"bed_leveling": bool(raw.get("bed_leveling", fb.get("bed_leveling", False))),
"override_machine": bool(raw.get("override_machine", fb.get("override_machine", False))),
}
def normalize_check(step=None):
raw = step if isinstance(step, dict) else {}
# Alte enge Grenzen (1.0 / 2.5) → aktueller Default (Düsen-Klumpen).
raw_delta = raw.get("max_delta_mm")
try:
if raw_delta is not None and raw_delta != "":
d = float(raw_delta)
if abs(d - 2.5) < 1e-9 or abs(d - 1.0) < 1e-9:
raw_delta = DEFAULT_CHECK["max_delta_mm"]
except (TypeError, ValueError):
raw_delta = raw.get("max_delta_mm")
return {
"type": "check",
"max_delta_mm": _num(
raw_delta, DEFAULT_CHECK["max_delta_mm"], LIMITS["max_delta_lo"], LIMITS["max_delta_hi"]
),
"timeout_min": _int(
raw.get("timeout_min"),
DEFAULT_CHECK["timeout_min"],
LIMITS["check_timeout_lo"],
LIMITS["check_timeout_hi"],
),
"x": _num(raw.get("x"), DEFAULT_CHECK["x"], LIMITS["x_min"], LIMITS["x_max"]),
"y": _num(raw.get("y"), DEFAULT_CHECK["y"], LIMITS["y_min"], LIMITS["y_max"]),
"ref_x": _num(raw.get("ref_x"), DEFAULT_CHECK["ref_x"], LIMITS["x_min"], LIMITS["x_max"]),
"ref_y": _num(raw.get("ref_y"), DEFAULT_CHECK["ref_y"], LIMITS["y_min"], LIMITS["y_max"]),
"z_clear": _num(
raw.get("z_clear"), DEFAULT_CHECK["z_clear"], LIMITS["z_travel_min"], LIMITS["z_travel_max"]
),
}
def normalize_steps(steps, fallback=None):
if not isinstance(steps, list) or not steps:
raise GrottiError("Ablauf braucht mindestens einen Schritt.", "ablauf")
out = []
for raw in steps:
if not isinstance(raw, dict):
raise GrottiError("Ungültiger Ablauf-Schritt.", "ablauf")
kind = (raw.get("type") or "").strip().lower()
if kind == "print":
out.append(normalize_print(raw, fallback))
elif kind == "cool":
out.append(normalize_cool(raw))
elif kind == "sweep":
out.append(normalize_sweep(raw))
elif kind == "check":
out.append(normalize_check(raw))
else:
raise GrottiError("Unbekannter Schritt: %s" % kind, "ablauf")
if not any(step["type"] == "print" for step in out):
raise GrottiError("Ablauf braucht mindestens einen Druck-Schritt.", "ablauf")
return out
def first_print_file_id(steps):
for step in steps:
if step.get("type") == "print":
return step["file_id"]
return None
def x_positions(sweep):
start = sweep["x_from"]
end = sweep["x_to"]
step = sweep["x_step"]
if abs(end - start) < 0.01:
return [round(start, 2)]
direction = 1.0 if end >= start else -1.0
positions = []
x = start
# inklusive Endpunkt, ohne Endlosschleife
for _ in range(200):
positions.append(round(x, 2))
if direction > 0 and x >= end - 0.01:
break
if direction < 0 and x <= end + 0.01:
break
x += direction * step
if direction > 0 and x > end:
positions.append(round(end, 2))
break
if direction < 0 and x < end:
positions.append(round(end, 2))
break
# Duplikate am Ende entfernen
cleaned = []
for pos in positions:
if not cleaned or abs(cleaned[-1] - pos) > 0.01:
cleaned.append(pos)
return cleaned
# Abkühlen: Düse zur Bettmitte (Park während Abkühlung).
COOL_PARK_X = 125.0
COOL_PARK_Y = 125.0
# Slice-Ende hebt Z nicht — relativ weit hoch, gegen axis_maximum Z gekappt.
COOL_PARK_LIFT = 40.0
COOL_PARK_Z_MAX_DEFAULT = 250.0
COOL_PARK_TOLERANCE_MM = 20.0
COOL_PARK_Z_TOLERANCE_MM = 2.0
def cool_script(cool):
fan = max(0, min(100, int(cool["fan_percent"])))
pwm = int(round(fan * 255 / 100)) if fan > 0 else 0
lines = [
"SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0",
"SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0",
]
if pwm > 0:
lines.append("M106 S%d" % pwm)
else:
lines.append("M107")
return "\n".join(lines)
def cool_park_z_now(live):
pos = (live or {}).get("position") or []
if len(pos) >= 3 and isinstance(pos[2], (int, float)):
return float(pos[2])
return None
def cool_park_z_max(live):
z_max = (live or {}).get("z_max")
if isinstance(z_max, (int, float)) and float(z_max) > 10:
return float(z_max)
return float(COOL_PARK_Z_MAX_DEFAULT)
def cool_park_lift_mm(live):
"""Relativer Z-Hub, begrenzt auf verbleibenden Weg bis Z-max."""
z_now = cool_park_z_now(live)
z_max = cool_park_z_max(live)
want = float(COOL_PARK_LIFT)
if z_now is None:
return want
headroom = z_max - z_now
if headroom < 0.5:
return 0.0
return min(want, headroom)
def cool_park_script(live=None):
"""Relativ Z hoch (gegen Z-max gekappt), dann Bettmitte."""
lift = cool_park_lift_mm(live)
lines = ["M83", "G90"]
if lift >= 0.5:
lines.extend([
"G91",
"G0 Z%.2f F1800" % lift,
"G90",
])
lines.extend([
"G0 X%.2f Y%.2f F6000" % (COOL_PARK_X, COOL_PARK_Y),
"M400",
"G90",
])
return "\n".join(lines)
def cool_parked(live, before_z=None, lift_mm=None, tolerance_mm=COOL_PARK_TOLERANCE_MM):
"""True wenn Mitte und geplanter Z-Hub angekommen ist (before_z/lift_mm nötig)."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
return False
dx = float(pos[0]) - COOL_PARK_X
dy = float(pos[1]) - COOL_PARK_Y
if (dx * dx + dy * dy) ** 0.5 > float(tolerance_mm):
return False
if before_z is None or lift_mm is None:
return False
if float(lift_mm) < 0.5:
return True
need = float(before_z) + float(lift_mm) - float(COOL_PARK_Z_TOLERANCE_MM)
return float(pos[2]) >= need
def idle_timeout_script(cool=None):
"""Leerlauf-Timeout verlängern — Stepper bleiben gehomed.
Ohne cool-Schritt: 1 h (Ablauf-Druck). Mit cool: Cool-Zeit + 10 min.
"""
if cool and isinstance(cool, dict) and cool.get("timeout_min") is not None:
seconds = max(900, int(cool["timeout_min"]) * 60 + 600)
else:
seconds = 3600
return "SET_IDLE_TIMEOUT TIMEOUT=%d" % seconds
def strip_end_motor_disable(content):
"""M84/M18 aus G-code entfernen — Slice-Ende schaltet sonst die Stepper aus.
Anycubic Present-Ende: `M84; disable motors` nach X250 Y220. Danach unhomed,
Abkühlen/Schieben scheitern. Kommentarzeilen bleiben.
"""
if content is None:
return content
if isinstance(content, bytes):
text = content.decode("utf-8", errors="replace")
as_bytes = True
else:
text = str(content)
as_bytes = False
out = []
for line in text.splitlines(keepends=True):
body = line.split(";", 1)[0].strip().upper()
if body == "M84" or body.startswith("M84 ") or body == "M18" or body.startswith("M18 "):
continue
out.append(line)
text = "".join(out)
return text.encode("utf-8") if as_bytes else text
def restore_position_script(live, sweep, allow_low_z=False):
"""Nach M84/Idle: letzte Toolhead-XYZ übernehmen (keine Bewegung).
Nur echte Moonraker-Position — keine erfundene Z. Nach Slice-M84 steht der Kopf
noch am Present; SET_KINEMATIC mit genau dieser XYZ ist korrekt. Zu niedrige Z → Abbruch.
"""
_ = allow_low_z
pos = (live or {}).get("position") or []
if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]):
raise GrottiError(
"Achsen nicht gehomed und letzte Position unbekannt. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu.",
"homed",
)
x, y, z = (float(v) for v in pos[:3])
min_z = float((sweep or {}).get("z_push") or 2) + 1
if z < min_z:
raise GrottiError(
"Achsen nicht gehomed und Düse zu tief gemeldet (Z %.1f mm) — unsicher. "
"Bei leerem Bett in Mainsail G28, dann Ablauf neu." % z,
"homed",
)
return "SET_KINEMATIC_POSITION X=%.3f Y=%.3f Z=%.3f" % (x, y, z), z
def cool_ready(live, cool):
bed = live.get("bed")
nozzle = live.get("nozzle")
if not isinstance(bed, (int, float)) or not isinstance(nozzle, (int, float)):
return False
return float(bed) <= float(cool["bed_max"]) and float(nozzle) <= float(cool["nozzle_max"])
def explain_printer_error(message):
"""Moonraker/Klipper-Text → kurze Grotti-Meldung."""
text = (message or "").strip()
low = text.lower()
if "must home axis first" in low or "must home" in low:
return (
"Achsen sind nicht gehomed. Gleich nach dem Druck abräumen "
"(bevor die Stepper ausgehen), oder in Mainsail homen — "
"Z nur bei leerem Bett."
)
if "is not homed" in low:
return (
"Achsen sind nicht gehomed. In Mainsail homen "
"(Z nur bei leerem Bett), dann nochmal abräumen."
)
if "no trigger on probe" in low:
return (
"Taste hat nicht ausgelöst (Wägezelle, voller Hub ohne Kontakt). "
"Bei leerem Bett meist falsche Z nach Leerlauf (zu wenig Hub) — "
"nicht Restteil. Düse auf Klumpen prüfen, dann Tasten erneut; "
"Grotti setzt vor dem Tasten ein Z-Budget."
)
return text or "Der Drucker hat den Befehl abgelehnt."
# Senken auf z_push: nie F3000 — bei falscher Z-Null war das Vollgas ins Bett.
SWEEP_FEED_DOWN = 400 # mm/min ≈ 6,7 mm/s
def sweep_go_back_high_script(sweep):
"""Nur hoch + nach hinten (y_from) — noch nicht senken."""
travel = sweep["z_travel"]
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
feed_travel = max(3000, int(round(float(sweep["speed"]) * 60)))
return "\n".join([
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, sweep["y_from"], feed_travel),
"M400",
])
def sweep_lower_to_push_script(sweep):
"""Langsam auf z_push — kein F3000-Sturz."""
push = sweep["z_push"]
return "\n".join([
"G90",
"G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN),
"M400",
])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — kein Senken.", "bahn")
z = float(pos[2])
need = max(float(min_z_mm), float(sweep["z_push"]) + 8.0)
if z < need:
raise GrottiError(
"Schieben gestoppt: nach hinten nur Z %.1f mm (braucht ≥ %.0f mm). "
"Senken würde ins Bett/Teil rauschen — Z-Null prüfen, leeres Bett: G28."
% (z, need),
"bahn",
)
def assert_at_push_height(live, sweep, tol_mm=1.5):
"""Wenn Z nicht bei z_push liegt, würde die Bahn das Teil nur überfliegen."""
pos = (live or {}).get("position") or []
if len(pos) < 3 or not isinstance(pos[2], (int, float)):
raise GrottiError("Schieben: Z-Position unbekannt — Abbruch.", "bahn")
z = float(pos[2])
want = float(sweep["z_push"])
if abs(z - want) > float(tol_mm):
raise GrottiError(
"Schieben gestoppt: Düse bei Z %.1f mm, erwartet %.1f mm. "
"Mit falscher Z-Höhe fährt die Bahn über das Teil (ohne Schrittverlust). "
"Z-Koordinate und reale Höhe passen oft nicht — "
"bei leerem Bett in Mainsail G28, dann neu."
% (z, want),
"bahn",
)
def sweep_script(sweep):
"""Absolute Bahn: anheben, nach hinten, langsam senken, nach vorn — nur Config-Spuren."""
travel = sweep["z_travel"]
push = sweep["z_push"]
y_from = sweep["y_from"]
y_to = sweep["y_to"]
# mm/s → mm/min für G0 F
feed = int(round(float(sweep["speed"]) * 60))
feed_travel = max(feed, 3000)
mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0
lines = [
"G90",
"G91",
"G0 Z%.2f F1800" % max(20.0, float(travel)),
"G90",
"G0 X%.2f Y%.2f F%d" % (mid_x, y_from, feed_travel),
]
for x in x_positions(sweep):
lines.append("G0 X%.2f Y%.2f F%d" % (x, y_from, feed_travel))
lines.append("G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN))
lines.append("G0 Y%.2f F%d" % (y_to, feed))
lines.append("G0 Z%.2f F%d" % (travel, feed_travel))
# hinten oben parken, weg von der Kiste
lines.append("G0 X%.2f Y%.2f Z%.2f F%d" % (mid_x, y_from, travel, feed_travel))
lines.append("M107")
lines.append("M400")
return "\n".join(lines)
def check_script(check, current_z=None):
"""Restteil-Tasten ohne G28 Z — mit aktivem Bettnetz (LeviQ).
Absolut auf Freiraum (z_clear bzw. aktuelle Z), Tastpunkt, ein PROBE.
Result is z= = Höhe in der echten Koordinate. Kein SET Z=100 / Bezug-Null —
das hat die Koordinate nur kaputt gemacht.
"""
check = normalize_check(check)
clear = max(25.0, float(check["z_clear"]))
z_travel = clear
if isinstance(current_z, (int, float)) and float(current_z) > clear:
z_travel = float(current_z)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % z_travel,
"G0 X%.2f Y%.2f F6000" % (check["x"], check["y"]),
"PROBE",
"M400",
"G90",
])
def check_z_from_live(live):
"""Nur Notfall: probe.last_z_result. Toolhead-Z nicht nutzen (Retract +2 mm)."""
probe_z = (live or {}).get("probe_z")
if isinstance(probe_z, (int, float)):
return float(probe_z), "probe"
return None, ""
def check_lift_script(check):
"""Nach dem Tasten absolut hoch — Düse nicht auf dem Bett lassen."""
check = normalize_check(check)
return "\n".join([
"G90",
"G0 Z%.2f F1800" % max(25.0, float(check["z_clear"])),
"M400",
])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step):
if not isinstance(step, dict):
return ""
kind = step.get("type")
if kind == "print":
return "Drucken"
if kind == "cool":
return "Abkühlen"
if kind == "sweep":
return "Schieben"
if kind == "check":
return "Tasten"
return kind or ""