Files
grabowski face699a28
Deploy test site / deploy (push) Successful in 57s
Optical QC: EtherCAT control plan with geizhals.de prices
User, 19 Oct 2026: plan the current EtherCAT setup, keep the 100 W servo,
check prices on geizhals.de.

- New docs/equipment/inline-qc-control-plan.md: decisions, bus layout
  (drives at the end of the line because the A6-200EC takes control
  power from the main supply), I/O list for EL1809/EL2809/EL3152/EL5101
  and the drive touch probe, PDO and DC notes from the A6-EC manual,
  geizhals.de price table (EUR 1,891.60 to 2,104.39 + USD 246.58 for the
  servo kits, THB 80k to 88k), NixOS/EtherCrab software, safety circuit
  (PNOZ s3, two contactors, dump valve), bench test with pass marks
  (parts about THB 64k, all reused), build order, spares, open points.
- EL3102 replaced by EL3152: the optoNCDT 1420 output is 4 to 20 mA.
- Spindle encoder index goes to an EL5101 instead of the drive touch
  probe; encoder 2,000 to 2,500 pulses.
- BOM: control lines split per part with geizhals.de prices (+30% high
  end for shipping and Thai duty/VAT, estimate); total USD 16,912 to
  33,061 (THB 0.56M to 1.09M). Doc, render caption and fleet calculator
  text now say USD 17k to 33k.
- README links the plan.
2026-10-19 10:43:59 +07:00

112 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Draw the part labels onto the investor render of the inline QC station.
# uv run --with pillow python docs/equipment/inline-qc-station/annotate_render.py
# Reads inline-qc-station-annotated-base.png and _anchors.json from render_annotated.py
# (FreeCAD) and writes inline-qc-station-annotated.png.
import json
import os
from PIL import Image, ImageChops, ImageDraw, ImageFont
D = os.path.dirname(os.path.abspath(__file__))
FONTS = "C:/Windows/Fonts/"
SCALE = 0.75 # 4000 px render, drawn at 3/4 size (anti-aliased)
TITLE = "Inline optical QC station"
SUBTITLE = ("Every record checked on both faces in one turn: surface, label, warp and centring. "
"Failed records leave the line automatically.")
FOOTER = ("CNX Vinyl, concept model (FreeCAD); bought parts drawn as outlines. 1.9 × 0.65 × 1.5 m, "
"front panels and lid removed for the view. Hardware about USD 17k to 33k per station (estimate).")
NAVY, GREY, LINE = (18, 53, 91), (85, 95, 105), (60, 74, 90)
def font(name, size):
return ImageFont.truetype(FONTS + name, size)
F_TITLE, F_SUB = font("seguisb.ttf", 76), font("segoeui.ttf", 36)
F_LAB, F_DESC, F_FOOT = font("seguisb.ttf", 40), font("segoeui.ttf", 31), font("segoeui.ttf", 27)
meta = json.load(open(os.path.join(D, "_anchors.json"), encoding="utf-8"))
img = Image.open(os.path.join(D, "inline-qc-station-annotated-base.png")).convert("RGB")
img = img.resize((round(img.width * SCALE), round(img.height * SCALE)), Image.LANCZOS)
bb = ImageChops.difference(img, Image.new("RGB", img.size, (255, 255, 255))).getbbox()
pad = 30
crop = (max(0, bb[0] - pad), max(0, bb[1] - pad), min(img.width, bb[2] + pad), min(img.height, bb[3] + pad))
img = img.crop(crop)
anchors = [dict(a, xy=(a["xy"][0] * SCALE - crop[0], a["xy"][1] * SCALE - crop[1])) for a in meta["anchors"]]
COL_W, GAP, TOP, BOTTOM, MX = 760, 90, 280, 140, 90
W = img.width + 2 * (MX + COL_W + GAP)
H = img.height + TOP + BOTTOM
canvas = Image.new("RGB", (W, H), (255, 255, 255))
ox, oy = MX + COL_W + GAP, TOP
canvas.paste(img, (ox, oy))
d = ImageDraw.Draw(canvas)
d.text((MX, 60), TITLE, font=F_TITLE, fill=NAVY)
d.text((MX + 4, 165), SUBTITLE, font=F_SUB, fill=GREY)
d.line([(MX, 238), (W - MX, 238)], fill=(215, 220, 226), width=2)
d.text((MX, H - 90), FOOTER, font=F_FOOT, fill=GREY)
# split: the left-most half of the anchors on the left, the rest on the right
order = sorted(anchors, key=lambda a: a["xy"][0])
half = (len(order) + 1) // 2
cols = {"L": order[:half], "R": order[half:]}
BLOCK = 108 # label height incl. spacing
def _cross(p1, p2, p3, p4):
def o(a, b, c):
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
return (o(p1, p2, p3) > 0) != (o(p1, p2, p4) > 0) and (o(p3, p4, p1) > 0) != (o(p3, p4, p2) > 0)
def place(items, edge_x):
"""Label slots kept near each anchor's height without overlapping, then the anchor-to-slot
assignment that makes no two leader lines cross (fewest crossings, then least drift)."""
import itertools
items = sorted(items, key=lambda a: a["xy"][1])
lo, hi = oy - 20, oy + img.height - BLOCK + 20
ys = [min(max(a["xy"][1] + oy - 26, lo), hi) for a in items]
for i in range(1, len(ys)):
ys[i] = max(ys[i], ys[i - 1] + BLOCK)
over = ys[-1] - hi
if over > 0:
ys = [y - over for y in ys]
for i in range(len(ys) - 2, -1, -1):
ys[i] = min(ys[i], ys[i + 1] - BLOCK)
best = None
for perm in itertools.permutations(range(len(items))):
segs = [((edge_x, ys[k] + 26), (items[i]["xy"][0] + ox, items[i]["xy"][1] + oy)) for k, i in enumerate(perm)]
n = sum(_cross(*segs[a], *segs[b]) for a in range(len(segs)) for b in range(a + 1, len(segs)))
drift = sum(abs(ys[k] + 26 - items[i]["xy"][1] - oy) for k, i in enumerate(perm))
score = n * 1e6 + drift
if best is None or score < best[0]:
best = (score, perm)
print("crossings", int(best[0] // 1e6))
return [(items[i], ys[k]) for k, i in enumerate(best[1])]
for side, items in cols.items():
edge = MX + COL_W + GAP - 20 if side == "L" else ox + img.width + 20
for a, y in place(items, edge):
ax, ay = a["xy"][0] + ox, a["xy"][1] + oy
# text in a fixed column, left-aligned; the leader leaves from the end of the title line,
# runs level to the column edge, then straight to the part
tl = d.textlength(a["title"], font=F_LAB)
ly = y + 26
if side == "L":
tx = MX
pts = [(tx + tl + 16, ly), (MX + COL_W + GAP - 20, ly), (ax, ay)]
else:
tx = ox + img.width + GAP
pts = [(tx - 16, ly), (ox + img.width + 20, ly), (ax, ay)]
d.line(pts, fill=LINE, width=3, joint="curve")
d.ellipse([ax - 10, ay - 10, ax + 10, ay + 10], fill=(255, 255, 255))
d.ellipse([ax - 7, ay - 7, ax + 7, ay + 7], fill=NAVY)
d.text((tx, y), a["title"], font=F_LAB, fill=NAVY)
d.text((tx, y + 52), a["desc"], font=F_DESC, fill=GREY)
out = os.path.join(D, "inline-qc-station-annotated.png")
canvas.save(out, optimize=True)
print("saved", out, canvas.size)