For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Produire, à partir du spec docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md, tous les fichiers dont Yan a besoin pour construire la tour : dessins cotés, gabarit papier 1:1, fichiers DXF, projets KiCad + Gerbers des deux PCB, BOM, guide d'assemblage.
Architecture: Un seul module de paramètres (params.py, classe Geom) porte toutes les cotes du spec, y compris les trois valeurs à mesurer (épaisseur des rondelles, longueur des barres d'alu, bagues des pots). Tous les générateurs (DXF, PDF, KiCad, BOM) lisent Geom et se régénèrent d'une commande (build.sh) quand les mesures arrivent. Chaque générateur est une fonction pure « Geom → fichier » testée avec pytest.
Tech Stack: Python 3.13 (/usr/local/bin/python3, qui voit aussi le module pcbnew), ezdxf 1.4 (DXF), reportlab 5 (PDF 1:1), matplotlib 3.11 (dessins cotés), KiCad 7.0.11 (pcbnew Python + kicad-cli) avec kicad-footprints installés dans /usr/share/kicad/footprints, poppler (pdfinfo, pdftotext) pour les tests, pytest 9, git.
IN = 25.4.t_rondelle = 1.65, l_barre = 1524.0, d_bague_pot = 7.5, d_bague_inter = 6.5. Aucun générateur ne code en dur une valeur dérivée de celles-ci.l_barre ≥ 1481 sinon 10 ; fente 5/16 po si l_barre ≥ 1451 sinon ¼ po ; zone de contrôle minimum 150.19·p + 23, LED à 11,5 + k·p, JST-XH coudés en face arrière, 330 Ω en série sur DATA IN, 100 nF par LED, masque noir, 2 couches. Broches JST : 1 = +5V, 2 = DATA, 3 = GND.22,52 + k·46,04, IN/OUT aux bouts, 8 cavaliers à souder (patrons A/B), bornier 5,08, 1000 µF, SN74AHCT1G125 optionnel avec cavalier de contournement, 4 trous M3.pcbnew (CreateEmptyBoard), sauvegardés puis rechargés avant de remplir les zones (le ZONE_FILLER plante sur une carte non sauvegardée en 7.0.11), fp.Flip() seulement après board.Add(fp).error, zéro élément non connecté. Les avertissements sont tolérés./home/claude/spectrum ; commit à la fin de chaque tâche. Les sorties générées vont dans out/ (versionné, c'est le livrable)./home/claude/spectrum/
params.py # Geom : toutes les cotes + dérivées (§3 du spec)
build.sh # régénère out/ au complet
drawings/__init__.py
drawings/dxf_parts.py # DXF : bloc, plaque du dessus, barre du haut
drawings/template_1to1.py # PDF 1:1 tuilé lettre (reportlab)
drawings/views.py # PDF cotés matplotlib : bloc+gabarit, plaque, assemblage
pcb/__init__.py
pcb/kicad_helpers.py # enveloppe pcbnew : Board, place_by_pad, track, via, zone, DRC, export
pcb/gen_barrette.py # génère out/pcb/barrette-28b/
pcb/gen_backplane.py # génère out/pcb/backplane-28b/
docs/gen_bom.py # génère out/BOM.md et out/BOM.csv depuis Geom
docs/GUIDE-ASSEMBLAGE.md # statique
tests/test_params.py
tests/test_dxf.py
tests/test_template.py
tests/test_views.py
tests/test_pcb_barrette.py
tests/test_pcb_backplane.py
tests/test_bom.py
out/ # livrables générés
Geom et testsFiles:
- Create: params.py
- Create: tests/test_params.py
- Create: .gitignore, pytest.ini
Interfaces:
- Produces: params.IN: float, params.Geom (dataclass gelée) avec les propriétés marge_tour, fente, pas_col, entretoise, pas_rang, largeur_tour, zone_tour, zone_controle, hauteur_pile, y_tiges, y_face_arriere, longueur_tige, barrette_long, z_bas_barrette, blocs_par_bande, n_blocs, bandes_necessaires, n_rondelles, n_tiges, l_base, x_controle et les méthodes x_col(c) -> float, z_rang(n) -> float, tiges() -> list[tuple[float,float]], cables() -> list[tuple[float,float]], trous_bloc() -> list[tuple[float,float]], trous_controle() -> list[tuple[float,float,float]].
cd /home/claude/spectrum
git init -q
printf '__pycache__/\n*.pyc\n.pytest_cache/\n*-backups/\nfp-info-cache\n' > .gitignore
printf '[pytest]\ntestpaths = tests\n' > pytest.ini
git add -A && git commit -qm "chore: dépôt spectrum, spec et plan" && git log --oneline | head -1
Créer tests/test_params.py :
import math
import pytest
from params import Geom, IN
def test_nominal_geometry_matches_spec():
g = Geom()
assert g.fente == pytest.approx(7.9375)
assert g.pas_col == pytest.approx(46.0375)
assert g.entretoise == pytest.approx(6.6)
assert g.pas_rang == pytest.approx(19.3)
assert g.largeur_tour == pytest.approx(27 * 46.0375 + 38.1)
assert g.zone_tour == pytest.approx(1331.1125)
assert g.hauteur_pile == pytest.approx(392.6)
assert g.x_col(1) == pytest.approx(44.05)
assert g.x_col(28) == pytest.approx(44.05 + 27 * 46.0375)
assert g.z_rang(1) == pytest.approx(12.95)
assert g.z_rang(20) == pytest.approx(12.95 + 19 * 19.3)
assert g.y_tiges == pytest.approx(51.75)
assert g.y_face_arriere == pytest.approx(83.5)
assert g.barrette_long == pytest.approx(19 * 19.3 + 23)
assert g.z_bas_barrette == pytest.approx(1.45)
def test_counts():
g = Geom()
assert g.n_blocs == 560
assert g.blocs_par_bande == 59
assert g.bandes_necessaires == 10
assert g.n_tiges == 56
assert len(g.tiges()) == 56
assert len(g.cables()) == 28
assert g.n_rondelles == 4 * 21 * 56 + 112
assert g.longueur_tige == pytest.approx(3.175 + 7 + 392.6 + 3.175 + 10 + 16)
def test_tige_and_cable_positions():
g = Geom()
t = g.tiges()
assert t[0] == pytest.approx((44.05 - 9.525, 51.75))
assert t[1] == pytest.approx((44.05 + 9.525, 51.75))
assert t[-1] == pytest.approx((g.x_col(28) + 9.525, 51.75))
assert g.cables()[0] == pytest.approx((44.05, 93.0))
h = g.trous_bloc()
assert h[0] == pytest.approx((9.525, 31.75))
assert h[1] == pytest.approx((28.575, 31.75))
def test_pas_rang_follows_measured_washer():
g = Geom(t_rondelle=1.52)
assert g.pas_rang == pytest.approx(12.7 + 4 * 1.52)
assert g.barrette_long == pytest.approx(19 * (12.7 + 6.08) + 23)
def test_length_rules():
assert Geom(l_barre=1524).marge_tour == 25
assert Geom(l_barre=1524).fente == pytest.approx(5 / 16 * IN)
assert Geom(l_barre=1470).marge_tour == 10
assert Geom(l_barre=1470).fente == pytest.approx(5 / 16 * IN)
assert Geom(l_barre=1440).marge_tour == 10
assert Geom(l_barre=1440).fente == pytest.approx(0.25 * IN)
assert Geom(l_barre=1524).zone_controle == pytest.approx(1524 - 1331.1125)
assert Geom(l_barre=1524).l_base == pytest.approx(1524)
def test_control_zone_holes():
g = Geom()
holes = g.trous_controle()
assert len(holes) == 5
xs = [h[0] for h in holes]
assert all(g.zone_tour < x < g.l_base for x in xs)
assert holes[0][2] == pytest.approx(7.5)
assert holes[4][2] == pytest.approx(6.5)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_params.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'params'
params.py"""Cotes du Spectrum 28 bandes (spec docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md).
Toutes les cotes sont en mm. Les quatre premiers champs sont des mesures à
remplacer par les valeurs réelles avant de régénérer les livrables.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from functools import cached_property
IN = 25.4
@dataclass(frozen=True)
class Geom:
# --- À MESURER (nominal en attendant) -----------------------------------
t_rondelle: float = 1.65 # épaisseur moyenne d'une rondelle ¼ SAE (pile de 40 ÷ 40)
l_barre: float = 60 * IN # longueur réelle des barres d'aluminium ⅛ × 5 po
d_bague_pot: float = 7.5 # Ø trou pour la bague des potentiomètres
d_bague_inter: float = 6.5 # Ø trou pour la bague de l'interrupteur
pas_pots: float = 22.0 # entraxe des 4 pots sur la carte PLATINUM (à mesurer)
y_controles: float = 40.0 # distance bord avant → axe des pots sur le dessus de la base
# --- Fixes (spec §3) ----------------------------------------------------
bloc_l: float = 1.5 * IN
bloc_h: float = 0.5 * IN
bloc_p: float = 2.5 * IN
n_col: int = 28
n_rang: int = 20
n_rond: int = 4
entraxe_tiges: float = 0.75 * IN
d_trou_bloc: float = 9 / 32 * IN
d_trou_plaque: float = 5 / 16 * IN
d_trou_cable: float = 0.5 * IN
plaque_prof: float = 5 * IN
y_face_avant: float = 20.0
y_cable: float = 93.0
ep_alu: float = IN / 8
barre_haut_larg: float = 3 * IN
zone_controle_min: float = 150.0
marge_barrette: float = 11.5
barrette_larg: float = 20.0
kerf_scie: float = IN / 8
bande_long: float = 96 * IN
rechange_blocs: int = 30
# --- Règles de longueur (spec §5.4) --------------------------------------
@cached_property
def marge_tour(self) -> float:
return 25.0 if self.l_barre >= 1481 else 10.0
@cached_property
def fente(self) -> float:
return (5 / 16) * IN if self.l_barre >= 1451 else 0.25 * IN
# --- Dérivées ------------------------------------------------------------
@property
def pas_col(self) -> float:
return self.bloc_l + self.fente
@property
def entretoise(self) -> float:
return self.n_rond * self.t_rondelle
@property
def pas_rang(self) -> float:
return self.bloc_h + self.entretoise
@property
def largeur_tour(self) -> float:
return (self.n_col - 1) * self.pas_col + self.bloc_l
@property
def zone_tour(self) -> float:
return self.largeur_tour + 2 * self.marge_tour
@property
def l_base(self) -> float:
return self.l_barre
@property
def zone_controle(self) -> float:
return self.l_base - self.zone_tour
@property
def x_controle(self) -> float:
"""Centre de la zone de contrôle (à droite de la tour)."""
return self.zone_tour + self.zone_controle / 2
@property
def hauteur_pile(self) -> float:
return self.n_rang * self.bloc_h + (self.n_rang + 1) * self.entretoise
@property
def y_tiges(self) -> float:
return self.y_face_avant + self.bloc_p / 2
@property
def y_face_arriere(self) -> float:
return self.y_face_avant + self.bloc_p
@property
def longueur_tige(self) -> float:
# plaque + écrou/rondelle dessous + pile + barre du haut + écrou borgne + marge
return self.ep_alu + 7.0 + self.hauteur_pile + self.ep_alu + 10.0 + 16.0
@property
def barrette_long(self) -> float:
return (self.n_rang - 1) * self.pas_rang + 2 * self.marge_barrette
@property
def z_bas_barrette(self) -> float:
return self.z_rang(1) - self.marge_barrette
@property
def n_blocs(self) -> int:
return self.n_col * self.n_rang
@property
def blocs_par_bande(self) -> int:
return int((self.bande_long + self.kerf_scie) // (self.bloc_l + self.kerf_scie))
@property
def bandes_necessaires(self) -> int:
return math.ceil((self.n_blocs + self.rechange_blocs) / self.blocs_par_bande)
@property
def n_tiges(self) -> int:
return 2 * self.n_col
@property
def n_rondelles(self) -> int:
# 4 par joint, 21 joints par tige, + 2 par tige sous les écrous (bas et haut)
return self.n_rond * (self.n_rang + 1) * self.n_tiges + 2 * self.n_tiges
# --- Positions -----------------------------------------------------------
def x_col(self, c: int) -> float:
"""Centre de la colonne c (1..28) depuis le bout gauche de la base."""
return self.marge_tour + self.bloc_l / 2 + (c - 1) * self.pas_col
def z_rang(self, n: int) -> float:
"""Centre de la rangée n (1..20) au-dessus du dessus de la plaque de base."""
return self.entretoise + self.bloc_h / 2 + (n - 1) * self.pas_rang
def tiges(self) -> list[tuple[float, float]]:
return [
(self.x_col(c) + s * self.entraxe_tiges / 2, self.y_tiges)
for c in range(1, self.n_col + 1)
for s in (-1, +1)
]
def cables(self) -> list[tuple[float, float]]:
return [(self.x_col(c), self.y_cable) for c in range(1, self.n_col + 1)]
def trous_bloc(self) -> list[tuple[float, float]]:
"""Trous du bloc : (x depuis la face latérale gauche, y depuis la face avant)."""
return [
(self.bloc_l / 2 - self.entraxe_tiges / 2, self.bloc_p / 2),
(self.bloc_l / 2 + self.entraxe_tiges / 2, self.bloc_p / 2),
]
def trous_controle(self) -> list[tuple[float, float, float]]:
"""(x, y, Ø) des 4 pots et de l'interrupteur sur le dessus de la base."""
xs = [self.x_controle + (i - 2.0) * self.pas_pots for i in range(4)]
holes = [(x, self.y_controles, self.d_bague_pot) for x in xs]
holes.append((self.x_controle + 2.0 * self.pas_pots, self.y_controles, self.d_bague_inter))
return holes
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_params.py -q 2>&1 | tail -3
Expected: 6 passed
cd /home/claude/spectrum && git add -A && git commit -qm "feat: paramètres Geom du spectrum 28 bandes" && git log --oneline | head -1
Files:
- Create: drawings/__init__.py (vide)
- Create: drawings/dxf_parts.py
- Create: tests/test_dxf.py
Interfaces:
- Consumes: params.Geom
- Produces: dxf_bloc(g, path) -> str, dxf_plaque_dessus(g, path) -> str, dxf_barre_haut(g, path) -> str (retournent le chemin écrit). Calques : CONTOUR (polyligne fermée), TROUS_TIGES, TROUS_CABLES, TROUS_CONTROLES, TEXTE.
Créer tests/test_dxf.py :
import ezdxf
import pytest
from params import Geom
from drawings.dxf_parts import dxf_bloc, dxf_plaque_dessus, dxf_barre_haut
def circles(path, layer):
msp = ezdxf.readfile(path).modelspace()
return [c for c in msp.query("CIRCLE") if c.dxf.layer == layer]
def test_dxf_bloc(tmp_path):
g = Geom()
p = dxf_bloc(g, str(tmp_path / "bloc.dxf"))
cs = circles(p, "TROUS_TIGES")
assert len(cs) == 2
assert sorted(c.dxf.center.x for c in cs) == pytest.approx([9.525, 28.575])
assert all(c.dxf.center.y == pytest.approx(31.75) for c in cs)
assert all(c.dxf.radius == pytest.approx(g.d_trou_bloc / 2) for c in cs)
poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")
assert len(poly) == 1 and poly[0].closed
xs = [pt[0] for pt in poly[0].get_points()]
ys = [pt[1] for pt in poly[0].get_points()]
assert max(xs) == pytest.approx(38.1) and max(ys) == pytest.approx(63.5)
def test_dxf_plaque_dessus(tmp_path):
g = Geom()
p = dxf_plaque_dessus(g, str(tmp_path / "plaque.dxf"))
assert len(circles(p, "TROUS_TIGES")) == 56
assert len(circles(p, "TROUS_CABLES")) == 28
assert len(circles(p, "TROUS_CONTROLES")) == 5
first = min(circles(p, "TROUS_TIGES"), key=lambda c: c.dxf.center.x)
assert first.dxf.center.x == pytest.approx(44.05 - 9.525)
assert first.dxf.radius == pytest.approx(g.d_trou_plaque / 2)
poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")[0]
xs = [pt[0] for pt in poly.get_points()]
ys = [pt[1] for pt in poly.get_points()]
assert max(xs) == pytest.approx(g.l_base) and max(ys) == pytest.approx(127.0)
def test_dxf_barre_haut(tmp_path):
g = Geom()
p = dxf_barre_haut(g, str(tmp_path / "barre.dxf"))
assert len(circles(p, "TROUS_TIGES")) == 56
assert len(circles(p, "TROUS_CABLES")) == 0
poly = ezdxf.readfile(p).modelspace().query("LWPOLYLINE[layer=='CONTOUR']")[0]
xs = [pt[0] for pt in poly.get_points()]
ys = [pt[1] for pt in poly.get_points()]
assert max(xs) == pytest.approx(g.zone_tour) and max(ys) == pytest.approx(76.2)
# les trous de la barre sont au même x que ceux de la plaque, et y mesuré depuis
# le bord avant de la barre (bord avant de la barre = face avant des blocs)
c = min(circles(p, "TROUS_TIGES"), key=lambda c: c.dxf.center.x)
assert c.dxf.center.y == pytest.approx(g.y_tiges - g.y_face_avant)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_dxf.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'drawings'
drawings/dxf_parts.py"""Fichiers DXF (R2010, mm) des pièces à percer/couper.
Origine : coin avant-gauche de chaque pièce, X vers la droite, Y vers l'arrière.
"""
from __future__ import annotations
import ezdxf
from params import Geom
LAYERS = {
"CONTOUR": 7,
"TROUS_TIGES": 1,
"TROUS_CABLES": 3,
"TROUS_CONTROLES": 5,
"TEXTE": 8,
}
def _doc():
doc = ezdxf.new("R2010", setup=True)
doc.header["$INSUNITS"] = 4 # mm
for name, color in LAYERS.items():
doc.layers.add(name, color=color)
return doc
def _rect(msp, w, h):
msp.add_lwpolyline([(0, 0), (w, 0), (w, h), (0, h)], close=True, dxfattribs={"layer": "CONTOUR"})
def _holes(msp, pts, d, layer):
for x, y in pts:
msp.add_circle((x, y), d / 2, dxfattribs={"layer": layer})
# croix de centrage pour le pointeau
msp.add_line((x - d, y), (x + d, y), dxfattribs={"layer": layer})
msp.add_line((x, y - d), (x, y + d), dxfattribs={"layer": layer})
def dxf_bloc(g: Geom, path: str) -> str:
doc = _doc()
msp = doc.modelspace()
_rect(msp, g.bloc_l, g.bloc_p)
_holes(msp, g.trous_bloc(), g.d_trou_bloc, "TROUS_TIGES")
msp.add_text(
f"BLOC {g.bloc_l:.1f} x {g.bloc_p:.1f} x {g.bloc_h:.1f} mm, 2 trous D{g.d_trou_bloc:.2f}",
dxfattribs={"layer": "TEXTE", "height": 2.0},
).set_placement((0, -5))
doc.saveas(path)
return path
def dxf_plaque_dessus(g: Geom, path: str) -> str:
doc = _doc()
msp = doc.modelspace()
_rect(msp, g.l_base, g.plaque_prof)
_holes(msp, g.tiges(), g.d_trou_plaque, "TROUS_TIGES")
_holes(msp, g.cables(), g.d_trou_cable, "TROUS_CABLES")
for x, y, d in g.trous_controle():
_holes(msp, [(x, y)], d, "TROUS_CONTROLES")
msp.add_line((g.zone_tour, 0), (g.zone_tour, g.plaque_prof), dxfattribs={"layer": "TEXTE"})
msp.add_text(
f"PLAQUE DU DESSUS {g.l_base:.0f} x {g.plaque_prof:.0f} mm - 56 trous D{g.d_trou_plaque:.2f} - "
f"28 trous D{g.d_trou_cable:.1f} - zone tour {g.zone_tour:.1f} - zone controle {g.zone_controle:.1f}",
dxfattribs={"layer": "TEXTE", "height": 4.0},
).set_placement((0, -8))
doc.saveas(path)
return path
def dxf_barre_haut(g: Geom, path: str) -> str:
doc = _doc()
msp = doc.modelspace()
_rect(msp, g.zone_tour, g.barre_haut_larg)
pts = [(x, y - g.y_face_avant) for x, y in g.tiges()]
_holes(msp, pts, g.d_trou_plaque, "TROUS_TIGES")
msp.add_text(
f"BARRE DU HAUT {g.zone_tour:.1f} x {g.barre_haut_larg:.1f} mm - 56 trous D{g.d_trou_plaque:.2f} - "
"bord avant = face avant des blocs",
dxfattribs={"layer": "TEXTE", "height": 4.0},
).set_placement((0, -8))
doc.saveas(path)
return path
Créer drawings/__init__.py vide : touch drawings/__init__.py.
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_dxf.py -q 2>&1 | tail -3
Expected: 3 passed
cd /home/claude/spectrum && git add -A && git commit -qm "feat: DXF bloc, plaque du dessus, barre du haut" && git log --oneline | head -1
Files:
- Create: drawings/template_1to1.py
- Create: tests/test_template.py
Interfaces:
- Consumes: params.Geom
- Produces: tuiles(longueur: float, pas: float = 240.0) -> list[float] (origines x des pages), mm2pt(v: float) -> float, pdf_gabarit_plaque(g, path) -> str.
Créer tests/test_template.py :
import subprocess
import pytest
from params import Geom
from drawings.template_1to1 import tuiles, mm2pt, pdf_gabarit_plaque
def test_tuiles():
assert tuiles(1524) == [0.0, 240.0, 480.0, 720.0, 960.0, 1200.0, 1440.0]
assert tuiles(240) == [0.0]
assert tuiles(241) == [0.0, 240.0]
def test_scale():
assert mm2pt(25.4) == pytest.approx(72.0)
assert mm2pt(46.0375) == pytest.approx(130.5, abs=0.05)
def test_pdf_pages_and_text(tmp_path):
g = Geom()
p = pdf_gabarit_plaque(g, str(tmp_path / "gabarit.pdf"))
info = subprocess.run(["pdfinfo", p], capture_output=True, text=True).stdout
assert "Pages:" in info
pages = int([l for l in info.splitlines() if l.startswith("Pages:")][0].split()[1])
assert pages == len(tuiles(g.l_base))
size = [l for l in info.splitlines() if l.startswith("Page size:")][0]
assert "792 x 612" in size # lettre paysage, points
txt = subprocess.run(["pdftotext", p, "-"], capture_output=True, text=True).stdout
assert "ECHELLE 100 mm" in txt
assert "Page 1 / " in txt
assert "IMPRIMER A 100 %" in txt
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_template.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'drawings.template_1to1'
drawings/template_1to1.py"""Gabarit papier à l'échelle 1:1 de la plaque du dessus, tuilé sur des pages lettre paysage.
Chaque page couvre 240 mm de longueur utile (+ 19 mm de recouvrement à droite).
On colle les pages bord à bord sur les traits de raccord, puis on colle le tout
sur la plaque d'aluminium et sur la barre du haut (serrées ensemble) pour pointer
les 56 trous de tiges, les 28 trous de câbles et les trous de contrôle.
"""
from __future__ import annotations
import math
from reportlab.lib.pagesizes import landscape, letter
from reportlab.pdfgen import canvas
from params import Geom
PT_PER_MM = 72.0 / 25.4
MARGE = 10.0 # marge d'impression, mm
PAS_TUILE = 240.0 # avancée par page, mm
LARG_UTILE = 259.0 # largeur dessinée par page, mm (240 + recouvrement)
def mm2pt(v: float) -> float:
return v * PT_PER_MM
def tuiles(longueur: float, pas: float = PAS_TUILE) -> list[float]:
n = max(1, math.ceil(longueur / pas))
return [i * pas for i in range(n)]
def _croix(c, x, y, d):
c.line(x - d, y, x + d, y)
c.line(x, y - d, x, y + d)
def pdf_gabarit_plaque(g: Geom, path: str) -> str:
page_w, page_h = landscape(letter)
c = canvas.Canvas(path, pagesize=(page_w, page_h))
c.setTitle("Gabarit 1:1 plaque du dessus - Spectrum 28 bandes")
pages = tuiles(g.l_base)
y0 = mm2pt(MARGE + 30) # la plaque est dessinée à 30 mm au-dessus de la marge (place pour le texte)
for i, x0 in enumerate(pages):
c.saveState()
c.translate(mm2pt(MARGE) - mm2pt(x0), y0)
# zone de dessin de cette page : x0 .. x0 + LARG_UTILE (clip)
p = c.beginPath()
p.rect(mm2pt(x0), -mm2pt(5), mm2pt(LARG_UTILE), mm2pt(g.plaque_prof + 10))
c.clipPath(p, stroke=0)
# contour de la plaque
c.setLineWidth(1.0)
c.rect(0, 0, mm2pt(g.l_base), mm2pt(g.plaque_prof))
# limite zone tour / zone de contrôle
c.setDash(3, 3)
c.line(mm2pt(g.zone_tour), 0, mm2pt(g.zone_tour), mm2pt(g.plaque_prof))
c.setDash()
# trous
c.setLineWidth(0.5)
for (x, y), d in [(t, g.d_trou_plaque) for t in g.tiges()] + [(t, g.d_trou_cable) for t in g.cables()]:
c.circle(mm2pt(x), mm2pt(y), mm2pt(d / 2))
_croix(c, mm2pt(x), mm2pt(y), mm2pt(d))
for x, y, d in g.trous_controle():
c.circle(mm2pt(x), mm2pt(y), mm2pt(d / 2))
_croix(c, mm2pt(x), mm2pt(y), mm2pt(d))
# numéros de colonnes
c.setFont("Helvetica", 7)
for col in range(1, g.n_col + 1):
c.drawCentredString(mm2pt(g.x_col(col)), mm2pt(g.plaque_prof - 6), str(col))
# traits de raccord (gauche = x0, droite = x0 + PAS_TUILE)
c.setLineWidth(0.3)
c.setDash(1, 2)
for xr in (x0, x0 + PAS_TUILE):
c.line(mm2pt(xr), -mm2pt(5), mm2pt(xr), mm2pt(g.plaque_prof + 5))
c.setDash()
c.restoreState()
# cartouche
c.setFont("Helvetica-Bold", 11)
c.drawString(mm2pt(MARGE), mm2pt(MARGE + 18), f"GABARIT 1:1 PLAQUE DU DESSUS - Page {i + 1} / {len(pages)} - x de {x0:.0f} a {x0 + PAS_TUILE:.0f} mm")
c.setFont("Helvetica", 9)
c.drawString(mm2pt(MARGE), mm2pt(MARGE + 12),
"IMPRIMER A 100 % (sans ajustement a la page). Coller les pages bord a bord sur les traits de raccord pointilles.")
c.drawString(mm2pt(MARGE), mm2pt(MARGE + 7),
f"Trous tiges D{g.d_trou_plaque:.2f} mm x56, trous cables D{g.d_trou_cable:.1f} mm x28, "
f"pots D{g.d_bague_pot:.1f} x4, interrupteur D{g.d_bague_inter:.1f}. Pas colonne {g.pas_col:.2f} mm.")
# barre d'échelle 100 mm
xs = mm2pt(MARGE)
ys = mm2pt(MARGE + 2)
c.setLineWidth(1.0)
c.line(xs, ys, xs + mm2pt(100), ys)
_croix(c, xs, ys, mm2pt(2))
_croix(c, xs + mm2pt(100), ys, mm2pt(2))
c.drawString(xs + mm2pt(102), ys - 3, "ECHELLE 100 mm (verifier a la regle)")
c.showPage()
c.save()
return path
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_template.py -q 2>&1 | tail -3
Expected: 3 passed
Run:
cd /home/claude/spectrum && python3 -c "
from params import Geom; from drawings.template_1to1 import pdf_gabarit_plaque
pdf_gabarit_plaque(Geom(), '/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit.pdf')" && pdftoppm -r 50 -png -f 1 -l 1 /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit.pdf /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit
Puis ouvrir /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/gabarit-1.png avec Read : on doit voir le contour de la plaque, 5 colonnes de 2 trous + 1 trou de câble, le cartouche et la barre d'échelle. Corriger les chevauchements de texte s'il y en a.
cd /home/claude/spectrum && git add -A && git commit -qm "feat: gabarit papier 1:1 tuilé de la plaque du dessus" && git log --oneline | head -1
Files:
- Create: drawings/views.py
- Create: tests/test_views.py
Interfaces:
- Consumes: params.Geom
- Produces: pdf_bloc_et_gabarit(g, path) -> str, pdf_plaque_et_barre(g, path) -> str, pdf_assemblage(g, path) -> str (PDF multi-pages matplotlib, texte extractible).
Créer tests/test_views.py :
import subprocess
from params import Geom
from drawings.views import pdf_bloc_et_gabarit, pdf_plaque_et_barre, pdf_assemblage
def text_of(p):
return subprocess.run(["pdftotext", "-layout", p, "-"], capture_output=True, text=True).stdout
def pages_of(p):
info = subprocess.run(["pdfinfo", p], capture_output=True, text=True).stdout
return int([l for l in info.splitlines() if l.startswith("Pages:")][0].split()[1])
def test_bloc_et_gabarit(tmp_path):
p = pdf_bloc_et_gabarit(Geom(), str(tmp_path / "bloc.pdf"))
t = text_of(p)
assert pages_of(p) == 2
assert "38,1" in t and "63,5" in t and "12,7" in t
assert "9,525" in t and "31,75" in t and "7,14" in t
assert "180" in t # gabarit : tourner le bloc de 180°
def test_plaque_et_barre(tmp_path):
g = Geom()
p = pdf_plaque_et_barre(g, str(tmp_path / "plaque.pdf"))
t = text_of(p)
assert pages_of(p) == 2
assert "46,04" in t and "19,05" in t and "51,75" in t and "93" in t
assert f"{g.zone_tour:.1f}".replace(".", ",") in t
def test_assemblage(tmp_path):
g = Geom()
p = pdf_assemblage(g, str(tmp_path / "asm.pdf"))
t = text_of(p)
assert pages_of(p) == 3
assert f"{g.largeur_tour:.1f}".replace(".", ",") in t
assert f"{g.hauteur_pile:.1f}".replace(".", ",") in t
assert "19,3" in t # pas_rang nominal, format fr()
assert "83,5" in t and "85,5" in t
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_views.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'drawings.views'
drawings/views.py"""Dessins cotés (PDF, matplotlib). Virgule décimale, cotes en mm sauf mention."""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.backends.backend_pdf import PdfPages # noqa: E402
from matplotlib.patches import Circle, Rectangle # noqa: E402
from params import Geom # noqa: E402
def fr(v: float, nd: int = 2) -> str:
return f"{v:.{nd}f}".rstrip("0").rstrip(".").replace(".", ",")
def cote(ax, x1, y1, x2, y2, texte, off=0.0, vertical=False, fs=7):
"""Ligne de cote avec flèches et texte. off décale la ligne de cote."""
if vertical:
ax.annotate("", (x1 + off, y1), (x1 + off, y2), arrowprops=dict(arrowstyle="<->", lw=0.6))
ax.plot([x1, x1 + off], [y1, y1], lw=0.3, color="k")
ax.plot([x2, x2 + off], [y2, y2], lw=0.3, color="k")
ax.text(x1 + off, (y1 + y2) / 2, " " + texte, fontsize=fs, va="center", ha="left") # horizontal : pdftotext le lit
else:
ax.annotate("", (x1, y1 + off), (x2, y1 + off), arrowprops=dict(arrowstyle="<->", lw=0.6))
ax.plot([x1, x1], [y1, y1 + off], lw=0.3, color="k")
ax.plot([x2, x2], [y2, y2 + off], lw=0.3, color="k")
ax.text((x1 + x2) / 2, y1 + off, texte, fontsize=fs, va="bottom", ha="center")
def _page(pdf, titre, sous_titre=""):
fig = plt.figure(figsize=(11, 8.5))
fig.text(0.02, 0.97, titre, fontsize=13, weight="bold", va="top")
if sous_titre:
fig.text(0.02, 0.935, sous_titre, fontsize=8, va="top")
fig.text(0.98, 0.02, "Spectrum 28 bandes - toutes cotes en mm", fontsize=7, ha="right")
return fig
def _rect(ax, x, y, w, h, **kw):
ax.add_patch(Rectangle((x, y), w, h, fill=False, lw=kw.pop("lw", 1.0), **kw))
def pdf_bloc_et_gabarit(g: Geom, path: str) -> str:
with PdfPages(path) as pdf:
# Page 1 : le bloc, vue de dessus + vue de face
fig = _page(pdf, "Bloc d'acrylique - vue de dessus et vue de face",
f"{g.n_blocs} blocs + {g.rechange_blocs} de rechange = {g.bandes_necessaires} bandes de 96 po "
f"({g.blocs_par_bande} blocs par bande, lame {fr(g.kerf_scie)}). Bloc symétrique : aucune orientation à respecter.")
ax = fig.add_axes([0.08, 0.35, 0.5, 0.55])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, 0, g.bloc_l, g.bloc_p)
for x, y in g.trous_bloc():
ax.add_patch(Circle((x, y), g.d_trou_bloc / 2, fill=False))
ax.plot([x - 4, x + 4], [y, y], lw=0.3, color="k"); ax.plot([x, x], [y - 4, y + 4], lw=0.3, color="k")
cote(ax, 0, 0, g.bloc_l, 0, f"{fr(g.bloc_l)} (1½ po)", off=-8)
cote(ax, 0, 0, 0, g.bloc_p, f"{fr(g.bloc_p)} (2½ po)", off=-8, vertical=True)
h = g.trous_bloc()
cote(ax, 0, g.bloc_p, h[0][0], g.bloc_p, f"{fr(h[0][0], 3)} (⅜ po)", off=5)
cote(ax, h[0][0], g.bloc_p, h[1][0], g.bloc_p, f"{fr(g.entraxe_tiges)} (¾ po)", off=12)
cote(ax, g.bloc_l, 0, g.bloc_l, h[0][1], f"{fr(h[0][1])} (1¼ po)", off=6, vertical=True)
ax.text(g.bloc_l / 2, h[0][1] + 10, f"2 trous Ø {fr(g.d_trou_bloc)} (9/32 po)\nrepli Ø 7,94 (5/16 po) si ça coince",
ha="center", fontsize=7)
ax.text(g.bloc_l / 2, -16, "FACE AVANT (en bas du dessin)", ha="center", fontsize=7)
ax.set_xlim(-25, g.bloc_l + 25); ax.set_ylim(-22, g.bloc_p + 22)
ax2 = fig.add_axes([0.62, 0.55, 0.33, 0.3])
ax2.set_aspect("equal"); ax2.axis("off")
_rect(ax2, 0, 0, g.bloc_l, g.bloc_h)
cote(ax2, 0, 0, g.bloc_l, 0, f"{fr(g.bloc_l)}", off=-5)
cote(ax2, g.bloc_l, 0, g.bloc_l, g.bloc_h, f"{fr(g.bloc_h)} (½ po)", off=4, vertical=True)
ax2.text(g.bloc_l / 2, g.bloc_h + 4, "Vue de face : faces dessus/dessous = lustrées d'usine,\navant/arrière/côtés = coupes de scie (givrées)",
ha="center", fontsize=7)
ax2.set_xlim(-10, g.bloc_l + 20); ax2.set_ylim(-10, g.bloc_h + 16)
pdf.savefig(fig); plt.close(fig)
# Page 2 : gabarit de perçage
fig = _page(pdf, "Gabarit de perçage des blocs (drill press)",
"Butée arrière contre la face avant du bloc, butée latérale contre une face latérale. "
"Percer, tourner le bloc de 180° autour de l'axe vertical, replaquer contre les mêmes butées, percer.")
ax = fig.add_axes([0.08, 0.2, 0.84, 0.65])
ax.set_aspect("equal"); ax.axis("off")
bx, by = 30, 30
_rect(ax, bx - 60, by - 12, g.bloc_l + 120, 12, hatch="////", lw=0.5) # butée arrière
_rect(ax, bx - 12, by, 12, g.bloc_p + 20, hatch="////", lw=0.5) # butée latérale
_rect(ax, bx, by, g.bloc_l, g.bloc_p, lw=1.2)
hx, hy = g.trous_bloc()[0]
ax.add_patch(Circle((bx + hx, by + hy), g.d_trou_bloc / 2, fill=True, color="k"))
ax.add_patch(Circle((bx + g.trous_bloc()[1][0], by + hy), g.d_trou_bloc / 2, fill=False, ls="--"))
ax.text(bx + hx, by + hy + 8, "mèche", ha="center", fontsize=7)
ax.text(bx + g.trous_bloc()[1][0], by + hy + 8, "2e trou après rotation 180°", ha="center", fontsize=7)
cote(ax, bx, by, bx + hx, by, f"{fr(hx, 3)} de la butée latérale", off=-18)
cote(ax, bx + g.bloc_l + 20, by, bx + g.bloc_l + 20, by + hy, f"{fr(hy)} de la butée arrière", off=0, vertical=True)
ax.text(bx - 55, by - 6, "BUTÉE ARRIÈRE", fontsize=7, va="center")
ax.text(bx - 10, by + g.bloc_p + 10, "BUTÉE\nLATÉRALE", fontsize=7, ha="center")
ax.text(bx + g.bloc_l / 2, by - 30,
f"Mèche {fr(g.d_trou_bloc)} mm (9/32 po) à plastique ou réaffûtée à 0°, basse vitesse, martyr MDF sous le bloc, papier protecteur en place.\n"
"Essai sur 60 blocs et 3 colonnes avant la série. L'entraxe des deux trous est garanti identique sur les 560 blocs par la rotation 180°.",
ha="center", fontsize=7)
ax.set_xlim(bx - 70, bx + g.bloc_l + 60); ax.set_ylim(by - 45, by + g.bloc_p + 30)
pdf.savefig(fig); plt.close(fig)
return path
def pdf_plaque_et_barre(g: Geom, path: str) -> str:
with PdfPages(path) as pdf:
fig = _page(pdf, "Plaque du dessus de la base (aluminium ⅛ × 5 po) - vue de dessus",
f"Longueur {fr(g.l_base, 1)} = zone tour {fr(g.zone_tour, 1)} + zone de contrôle {fr(g.zone_controle, 1)}. "
f"56 trous Ø {fr(g.d_trou_plaque)} (tiges), 28 trous Ø {fr(g.d_trou_cable, 1)} (câbles), 4 pots Ø {fr(g.d_bague_pot, 1)}, interrupteur Ø {fr(g.d_bague_inter, 1)}.")
ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, 0, g.l_base, g.plaque_prof)
for x, y in g.tiges():
ax.add_patch(Circle((x, y), g.d_trou_plaque / 2, fill=False, lw=0.4))
for x, y in g.cables():
ax.add_patch(Circle((x, y), g.d_trou_cable / 2, fill=False, lw=0.4))
for x, y, d in g.trous_controle():
ax.add_patch(Circle((x, y), d / 2, fill=False, lw=0.4))
ax.plot([g.zone_tour, g.zone_tour], [0, g.plaque_prof], lw=0.4, ls="--", color="k")
cote(ax, 0, 0, g.l_base, 0, f"{fr(g.l_base, 1)}", off=-40)
cote(ax, 0, 0, g.zone_tour, 0, f"zone tour {fr(g.zone_tour, 1)}", off=-22)
cote(ax, g.zone_tour, 0, g.l_base, 0, f"contrôle {fr(g.zone_controle, 1)}", off=-22)
cote(ax, 0, g.plaque_prof, g.x_col(1), g.plaque_prof, f"{fr(g.x_col(1))}", off=12)
cote(ax, g.x_col(1), g.plaque_prof, g.x_col(2), g.plaque_prof, f"pas {fr(g.pas_col)}", off=12)
t = g.tiges()
cote(ax, t[0][0], g.plaque_prof, t[1][0], g.plaque_prof, f"{fr(g.entraxe_tiges)}", off=30)
cote(ax, g.l_base, 0, g.l_base, g.y_tiges, f"{fr(g.y_tiges)}", off=15, vertical=True)
cote(ax, g.l_base, 0, g.l_base, g.y_cable, f"{fr(g.y_cable)}", off=35, vertical=True)
cote(ax, g.l_base, 0, g.l_base, g.plaque_prof, f"{fr(g.plaque_prof)}", off=55, vertical=True)
ax.text(g.l_base / 2, -70, "Bord avant en bas. Face avant des blocs à y = 20 ; les trous de câbles (y = 93) sont derrière les barrettes.",
ha="center", fontsize=7)
ax.set_xlim(-30, g.l_base + 90); ax.set_ylim(-80, g.plaque_prof + 45)
pdf.savefig(fig); plt.close(fig)
fig = _page(pdf, "Barre du haut (aluminium ⅛ × 3 po) - vue de dessus",
f"{fr(g.zone_tour, 1)} × {fr(g.barre_haut_larg, 1)}, 56 trous Ø {fr(g.d_trou_plaque)} au même patron que la plaque. "
"Bord avant = face avant des blocs. Percer serrée sur la plaque du dessus avec le gabarit 1:1.")
ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, 0, g.zone_tour, g.barre_haut_larg)
for x, y in g.tiges():
ax.add_patch(Circle((x, y - g.y_face_avant), g.d_trou_plaque / 2, fill=False, lw=0.4))
cote(ax, 0, 0, g.zone_tour, 0, f"{fr(g.zone_tour, 1)}", off=-25)
cote(ax, g.zone_tour, 0, g.zone_tour, g.y_tiges - g.y_face_avant, f"{fr(g.y_tiges - g.y_face_avant)}", off=15, vertical=True)
cote(ax, g.zone_tour, 0, g.zone_tour, g.barre_haut_larg, f"{fr(g.barre_haut_larg, 1)}", off=35, vertical=True)
ax.set_xlim(-30, g.zone_tour + 70); ax.set_ylim(-50, g.barre_haut_larg + 30)
pdf.savefig(fig); plt.close(fig)
return path
def pdf_assemblage(g: Geom, path: str) -> str:
y_bar = g.y_face_arriere + 2.0 # ruban mousse 2 mm
y_bar_arr = y_bar + 1.6 # face arrière de la barrette
with PdfPages(path) as pdf:
# Vue de face
fig = _page(pdf, "Assemblage - vue de face",
f"{g.n_col} colonnes × {g.n_rang} rangées. Largeur des blocs {fr(g.largeur_tour, 1)}, pile {fr(g.hauteur_pile, 1)}, "
f"pas colonne {fr(g.pas_col)}, pas rangée {fr(g.pas_rang)} (bloc {fr(g.bloc_h)} + {g.n_rond} rondelles × {fr(g.t_rondelle)}).")
ax = fig.add_axes([0.03, 0.15, 0.94, 0.7])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, -70, g.l_base, 70, lw=1.0) # base (hauteur 2¾ po = 69,9)
for c in range(1, g.n_col + 1):
x = g.x_col(c)
for n in range(1, g.n_rang + 1):
_rect(ax, x - g.bloc_l / 2, g.z_rang(n) - g.bloc_h / 2, g.bloc_l, g.bloc_h, lw=0.3)
for s in (-1, 1):
ax.plot([x + s * g.entraxe_tiges / 2] * 2, [0, g.hauteur_pile + g.ep_alu + 6], lw=0.4, color="0.4")
_rect(ax, g.marge_tour, g.hauteur_pile, g.largeur_tour, g.ep_alu, lw=0.8) # barre du haut
cote(ax, g.x_col(1) - g.bloc_l / 2, 0, g.x_col(28) + g.bloc_l / 2, 0, f"{fr(g.largeur_tour, 1)}", off=-85)
cote(ax, g.l_base + 10, 0, g.l_base + 10, g.hauteur_pile, f"{fr(g.hauteur_pile, 1)}", off=0, vertical=True)
cote(ax, g.l_base + 10, g.z_rang(1), g.l_base + 10, g.z_rang(2), f"{fr(g.pas_rang)}", off=25, vertical=True)
cote(ax, g.x_col(1), g.hauteur_pile + 10, g.x_col(2), g.hauteur_pile + 10, f"{fr(g.pas_col)}", off=10)
ax.set_xlim(-20, g.l_base + 70); ax.set_ylim(-95, g.hauteur_pile + 40)
pdf.savefig(fig); plt.close(fig)
# Vue de côté (coupe dans une colonne)
fig = _page(pdf, "Assemblage - vue de côté (coupe dans une colonne)",
f"Face avant des blocs à y = {fr(g.y_face_avant)}, tiges à y = {fr(g.y_tiges)}, face arrière à y = {fr(g.y_face_arriere)}, "
f"ruban mousse 2 mm, barrette de {fr(y_bar)} à {fr(y_bar_arr)}, trou de câble à y = {fr(g.y_cable)}.")
ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, -70, g.plaque_prof, 70, lw=1.0)
for n in range(1, g.n_rang + 1):
_rect(ax, g.y_face_avant, g.z_rang(n) - g.bloc_h / 2, g.bloc_p, g.bloc_h, lw=0.3)
# rondelles (4 par joint) représentées par un rectangle
_rect(ax, g.y_tiges - 7.9, g.z_rang(n) + g.bloc_h / 2, 15.8, g.entretoise, lw=0.2)
ax.plot([g.y_tiges, g.y_tiges], [-10, g.hauteur_pile + 12], lw=0.8, color="0.3")
_rect(ax, y_bar, g.z_bas_barrette, 1.6, g.barrette_long, lw=0.6)
for n in range(1, g.n_rang + 1):
ax.plot(y_bar, g.z_rang(n), marker="s", ms=3, color="k")
_rect(ax, g.y_face_avant, g.hauteur_pile, g.barre_haut_larg, g.ep_alu, lw=0.8)
ax.add_patch(Circle((g.y_cable, -3), g.d_trou_cable / 2, fill=False, lw=0.4))
cote(ax, 0, 0, g.y_face_avant, 0, f"{fr(g.y_face_avant)}", off=-20)
cote(ax, 0, 0, g.y_tiges, 0, f"{fr(g.y_tiges)}", off=-32)
cote(ax, 0, 0, g.y_face_arriere, 0, f"{fr(g.y_face_arriere)}", off=-44)
cote(ax, 0, 0, y_bar, 0, f"{fr(y_bar)} (barrette)", off=-56)
cote(ax, 0, 0, g.y_cable, 0, f"{fr(g.y_cable)} (câble)", off=-68)
cote(ax, 0, 0, g.plaque_prof, 0, f"{fr(g.plaque_prof)}", off=-80)
cote(ax, g.plaque_prof + 15, 0, g.plaque_prof + 15, g.z_rang(1), f"{fr(g.z_rang(1))}", off=0, vertical=True)
cote(ax, g.plaque_prof + 15, 0, g.plaque_prof + 15, g.hauteur_pile, f"{fr(g.hauteur_pile, 1)}", off=18, vertical=True)
ax.set_xlim(-15, g.plaque_prof + 60); ax.set_ylim(-95, g.hauteur_pile + 30)
pdf.savefig(fig); plt.close(fig)
# Vue de dessus de la base (position de la tour, zone de contrôle)
fig = _page(pdf, "Assemblage - vue de dessus de la base",
f"Zone tour {fr(g.zone_tour, 1)} à gauche, zone de contrôle {fr(g.zone_controle, 1)} à droite (4 pots + interrupteur sur le dessus, jacks à l'arrière).")
ax = fig.add_axes([0.03, 0.3, 0.94, 0.5])
ax.set_aspect("equal"); ax.axis("off")
_rect(ax, 0, 0, g.l_base, g.plaque_prof)
for c in range(1, g.n_col + 1):
_rect(ax, g.x_col(c) - g.bloc_l / 2, g.y_face_avant, g.bloc_l, g.bloc_p, lw=0.3)
_rect(ax, g.x_col(c) - g.barrette_larg / 2, y_bar, g.barrette_larg, 1.6, lw=0.3)
for x, y, d in g.trous_controle():
ax.add_patch(Circle((x, y), d / 2, fill=False, lw=0.4))
ax.plot([g.zone_tour, g.zone_tour], [0, g.plaque_prof], lw=0.4, ls="--", color="k")
cote(ax, 0, 0, g.zone_tour, 0, f"{fr(g.zone_tour, 1)}", off=-25)
cote(ax, g.zone_tour, 0, g.l_base, 0, f"{fr(g.zone_controle, 1)}", off=-25)
ax.set_xlim(-20, g.l_base + 20); ax.set_ylim(-45, g.plaque_prof + 20)
pdf.savefig(fig); plt.close(fig)
return path
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_views.py -q 2>&1 | tail -3
Expected: 3 passed
Run:
cd /home/claude/spectrum && S=/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad && python3 -c "
from params import Geom; from drawings.views import *
g=Geom(); pdf_bloc_et_gabarit(g,'$S/v1.pdf'); pdf_plaque_et_barre(g,'$S/v2.pdf'); pdf_assemblage(g,'$S/v3.pdf')" && for f in v1 v2 v3; do pdftoppm -r 45 -png $S/$f.pdf $S/$f; done && ls $S/v*.png
Ouvrir chaque PNG avec Read. Corriger les cotes qui se chevauchent (ajuster off) ou les textes coupés (ajuster set_xlim/set_ylim). Relancer les tests après chaque correction.
cd /home/claude/spectrum && git add -A && git commit -qm "feat: dessins cotés bloc/gabarit, plaque/barre, assemblage" && git log --oneline | head -1
Files:
- Create: pcb/__init__.py (vide)
- Create: pcb/kicad_helpers.py
- Create: pcb/gen_barrette.py
- Create: tests/test_pcb_barrette.py
Interfaces:
- Consumes: params.Geom
- Produces dans pcb/kicad_helpers.py : FP_DIR, mm, V(x, y), tomm(v) -> tuple, classe Board avec net(name), footprint(lib, name, ref, x, y, rot=0, back=False, value=""), place_by_pad(fp, pad_no, x, y), connect(fp, pad_no, net), pad_xy(fp, pad_no), track(pts, width, net, layer=F_Cu), via(x, y, net), rect_edge(x0, y0, x1, y1), zone(layer, net, pts), text(s, x, y, layer=F_SilkS, size=1.0, rot=0), save(path) ; fonctions fill_and_save(path), run_drc(path) -> tuple[int, int] (erreurs, non-connectés), export_gerbers(path, outdir) -> list[str], courtyard_bbox(fp) -> tuple[float, float, float, float].
- Produces dans pcb/gen_barrette.py : build_barrette(g, outdir) -> str (chemin du .kicad_pcb), constantes X_GND = 3.5, X_5V = 16.5.
Créer tests/test_pcb_barrette.py :
import os
import pcbnew
import pytest
from params import Geom
from pcb.kicad_helpers import run_drc, export_gerbers, courtyard_bbox, tomm
from pcb.gen_barrette import build_barrette
@pytest.fixture(scope="module")
def board_path(tmp_path_factory):
out = tmp_path_factory.mktemp("barrette")
return build_barrette(Geom(), str(out))
def test_counts_and_size(board_path):
b = pcbnew.LoadBoard(board_path)
refs = [fp.GetReference() for fp in b.GetFootprints()]
assert sum(r.startswith("D") for r in refs) == 20
assert sum(r.startswith("C") for r in refs) == 20
assert refs.count("R1") == 1 and refs.count("J1") == 1 and refs.count("J2") == 1
bb = b.GetBoardEdgesBoundingBox()
g = Geom()
assert pcbnew.ToMM(bb.GetWidth()) == pytest.approx(g.barrette_larg, abs=0.2)
assert pcbnew.ToMM(bb.GetHeight()) == pytest.approx(g.barrette_long, abs=0.2)
def test_led_pitch_and_chain(board_path):
b = pcbnew.LoadBoard(board_path)
g = Geom()
fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
ys = [tomm(fps[f"D{k}"].GetPosition())[1] for k in range(1, 21)]
# LED1 est en bas (y grand), LED20 en haut ; pas = pas_rang ; symétrie
assert ys[0] == pytest.approx(g.barrette_long - g.marge_barrette)
assert ys[19] == pytest.approx(g.marge_barrette)
for k in range(19):
assert ys[k] - ys[k + 1] == pytest.approx(g.pas_rang)
for k in range(1, 20):
assert fps[f"D{k}"].FindPadByNumber("2").GetNetname() == fps[f"D{k+1}"].FindPadByNumber("4").GetNetname()
assert fps["J1"].FindPadByNumber("1").GetNetname() == "+5V"
assert fps["J1"].FindPadByNumber("3").GetNetname() == "GND"
assert fps["J1"].FindPadByNumber("2").GetNetname() == "DIN"
assert fps["J2"].FindPadByNumber("2").GetNetname() == "D20"
assert fps["D1"].FindPadByNumber("4").GetNetname() == "D0"
assert fps["R1"].FindPadByNumber("1").GetNetname() == "DIN" # pad 1 (x = 9,05) côté J1
assert fps["R1"].FindPadByNumber("2").GetNetname() == "D0" # pad 2 (x = 10,95) vers LED1
def test_connectors_inside_board_and_symmetric(board_path):
b = pcbnew.LoadBoard(board_path)
g = Geom()
fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
for ref in ("J1", "J2"):
assert fps[ref].IsFlipped()
x0, y0, x1, y1 = courtyard_bbox(fps[ref])
assert x0 >= 0 and y0 >= 0 and x1 <= g.barrette_larg and y1 <= g.barrette_long
j1 = tomm(fps["J1"].FindPadByNumber("2").GetPosition())
j2 = tomm(fps["J2"].FindPadByNumber("2").GetPosition())
assert j1 == pytest.approx((5.0, g.barrette_long - 5.5))
assert j2 == pytest.approx((15.0, 5.5))
def test_drc_clean(board_path):
errors, unconnected = run_drc(board_path)
assert unconnected == 0
assert errors == 0
def test_gerbers(board_path):
files = export_gerbers(board_path, os.path.join(os.path.dirname(board_path), "gerbers"))
names = [os.path.basename(f) for f in files]
for layer in ("F_Cu", "B_Cu", "F_Paste", "F_Silkscreen", "B_Silkscreen", "F_Mask", "B_Mask", "Edge_Cuts"):
assert any(layer in n for n in names), layer
assert any(n.endswith(".drl") for n in names)
assert any(n.endswith(".gbrjob") for n in names)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_barrette.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'pcb'
pcb/kicad_helpers.py"""Enveloppe mince autour de pcbnew (KiCad 7.0.11) pour construire des cartes par script.
Pièges connus en 7.0.11 :
- fp.Flip() plante si le footprint n'est pas encore sur la carte : toujours board.Add(fp) avant.
- ZONE_FILLER.Fill() plante sur une carte créée en mémoire : sauvegarder, recharger, remplir, resauvegarder.
- VECTOR2I n'a pas d'opérateurs arithmétiques fiables côté Python : calculer en entiers.
"""
from __future__ import annotations
import glob
import os
import re
import subprocess
import pcbnew
FP_DIR = "/usr/share/kicad/footprints"
mm = pcbnew.FromMM
def V(x: float, y: float) -> pcbnew.VECTOR2I:
return pcbnew.VECTOR2I(mm(x), mm(y))
def tomm(v) -> tuple[float, float]:
return (pcbnew.ToMM(v.x), pcbnew.ToMM(v.y))
class Board:
def __init__(self, clearance: float = 0.2, track: float = 0.3, edge_clearance: float = 0.3):
self.b = pcbnew.CreateEmptyBoard()
ds = self.b.GetDesignSettings()
ds.SetCopperLayerCount(2)
nc = ds.m_NetSettings.m_DefaultNetClass
nc.SetClearance(mm(clearance))
nc.SetTrackWidth(mm(track))
nc.SetViaDiameter(mm(0.8))
nc.SetViaDrill(mm(0.4))
ds.m_TrackMinWidth = mm(0.2)
ds.m_ViasMinSize = mm(0.6)
ds.m_MinThroughDrill = mm(0.3)
ds.m_CopperEdgeClearance = mm(edge_clearance)
self.nets: dict[str, pcbnew.NETINFO_ITEM] = {}
# --- nets / footprints ---------------------------------------------------
def net(self, name: str) -> pcbnew.NETINFO_ITEM:
if name not in self.nets:
ni = pcbnew.NETINFO_ITEM(self.b, name)
self.b.Add(ni)
self.nets[name] = ni
return self.nets[name]
def footprint(self, lib: str, name: str, ref: str, x: float, y: float,
rot: float = 0, back: bool = False, value: str = "") -> pcbnew.FOOTPRINT:
fp = pcbnew.FootprintLoad(f"{FP_DIR}/{lib}.pretty", name)
if fp is None:
raise FileNotFoundError(f"{lib}:{name}")
fp.SetReference(ref)
if value:
fp.SetValue(value)
self.b.Add(fp) # AVANT Flip (sinon segfault)
fp.SetPosition(V(x, y))
if back:
fp.Flip(V(x, y), False)
fp.SetOrientationDegrees(rot)
return fp
def place_by_pad(self, fp: pcbnew.FOOTPRINT, pad_no: str, x: float, y: float) -> None:
"""Déplace le footprint pour que le pad `pad_no` tombe en (x, y)."""
p = fp.FindPadByNumber(pad_no).GetPosition()
pos = fp.GetPosition()
fp.SetPosition(pcbnew.VECTOR2I(pos.x + (mm(x) - p.x), pos.y + (mm(y) - p.y)))
def connect(self, fp: pcbnew.FOOTPRINT, pad_no: str, net: str) -> None:
fp.FindPadByNumber(pad_no).SetNet(self.net(net))
def pad_xy(self, fp: pcbnew.FOOTPRINT, pad_no: str) -> tuple[float, float]:
return tomm(fp.FindPadByNumber(pad_no).GetPosition())
# --- cuivre ------------------------------------------------------------
def track(self, pts, width: float, net: str, layer=pcbnew.F_Cu) -> None:
for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
t = pcbnew.PCB_TRACK(self.b)
t.SetStart(V(x1, y1)); t.SetEnd(V(x2, y2))
t.SetWidth(mm(width)); t.SetLayer(layer); t.SetNet(self.net(net))
self.b.Add(t)
def via(self, x: float, y: float, net: str, drill: float = 0.4, dia: float = 0.8) -> None:
v = pcbnew.PCB_VIA(self.b)
v.SetPosition(V(x, y)); v.SetDrill(mm(drill)); v.SetWidth(mm(dia))
v.SetViaType(pcbnew.VIATYPE_THROUGH); v.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
v.SetNet(self.net(net))
self.b.Add(v)
def zone(self, layer, net: str, pts, clearance: float = 0.3, min_w: float = 0.25) -> pcbnew.ZONE:
z = pcbnew.ZONE(self.b)
z.SetLayer(layer); z.SetNet(self.net(net))
z.SetLocalClearance(mm(clearance)); z.SetMinThickness(mm(min_w))
z.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL)
ol = z.Outline(); ol.NewOutline()
for x, y in pts:
ol.Append(mm(x), mm(y))
self.b.Add(z)
return z
# --- divers ------------------------------------------------------------
def rect_edge(self, x0: float, y0: float, x1: float, y1: float) -> None:
s = pcbnew.PCB_SHAPE(self.b)
s.SetShape(pcbnew.SHAPE_T_RECT)
s.SetStart(V(x0, y0)); s.SetEnd(V(x1, y1))
s.SetLayer(pcbnew.Edge_Cuts); s.SetWidth(mm(0.1))
self.b.Add(s)
def text(self, s: str, x: float, y: float, layer=pcbnew.F_SilkS, size: float = 1.0, rot: float = 0) -> None:
t = pcbnew.PCB_TEXT(self.b)
t.SetText(s); t.SetPosition(V(x, y)); t.SetLayer(layer)
t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
t.SetTextThickness(mm(max(0.12, size * 0.15)))
t.SetTextAngleDegrees(rot)
if layer in (pcbnew.B_SilkS, pcbnew.B_Cu, pcbnew.B_Fab):
t.SetMirrored(True)
self.b.Add(t)
def save(self, path: str) -> str:
os.makedirs(os.path.dirname(path), exist_ok=True)
pcbnew.SaveBoard(path, self.b)
return path
def fill_and_save(path: str) -> None:
b = pcbnew.LoadBoard(path)
pcbnew.ZONE_FILLER(b).Fill(b.Zones())
b.BuildConnectivity()
pcbnew.SaveBoard(path, b)
def run_drc(path: str) -> tuple[int, int]:
"""Retourne (nb de violations de sévérité error, nb d'éléments non connectés)."""
b = pcbnew.LoadBoard(path)
pcbnew.ZONE_FILLER(b).Fill(b.Zones())
b.BuildConnectivity()
report = path.replace(".kicad_pcb", "-drc.txt")
pcbnew.WriteDRCReport(b, report, pcbnew.EDA_UNITS_MILLIMETRES, True)
txt = open(report, encoding="utf-8").read()
unconnected = int(re.search(r"Found (\d+) unconnected", txt).group(1))
# on ne compte que les violations de sévérité error dans la section DRC
drc_section = txt.split("** Found")[1] if "** Found" in txt else ""
errors = len(re.findall(r"Severity: error", drc_section.split("unconnected")[0]))
return errors, unconnected
def export_gerbers(path: str, outdir: str) -> list[str]:
os.makedirs(outdir, exist_ok=True)
layers = "F.Cu,B.Cu,F.Paste,B.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts"
subprocess.run(["kicad-cli", "pcb", "export", "gerbers", "--output", outdir + "/",
"--layers", layers, "--no-protel-ext", "--subtract-soldermask", path],
check=True, capture_output=True)
subprocess.run(["kicad-cli", "pcb", "export", "drill", "--output", outdir + "/",
"--format", "excellon", "--excellon-units", "mm", "--generate-map",
"--map-format", "pdf", path], check=True, capture_output=True)
return sorted(glob.glob(os.path.join(outdir, "*")))
def courtyard_bbox(fp: pcbnew.FOOTPRINT) -> tuple[float, float, float, float]:
layer = pcbnew.B_CrtYd if fp.IsFlipped() else pcbnew.F_CrtYd
bb = fp.GetCourtyard(layer).BBox()
return (pcbnew.ToMM(bb.GetLeft()), pcbnew.ToMM(bb.GetTop()),
pcbnew.ToMM(bb.GetRight()), pcbnew.ToMM(bb.GetBottom()))
Créer pcb/__init__.py vide : touch pcb/__init__.py.
pcb/gen_barrette.pyRepères KiCad : X vers la droite, Y vers le bas. Le bout IN est en bas de la carte (y = L), LED1 juste au-dessus, data vers le haut (−Y). LED tournées de 180° : pad 4 (DIN) en bas-gauche, pad 2 (DOUT) en haut-droite, pad 1 (VDD) en bas-droite, pad 3 (VSS) en haut-gauche. Rail +5V à droite (x = 16,5), rail GND à gauche (x = 3,5), zone GND sur B.Cu.
"""Barrette LED 28B-COL : 20 × WS2812B au pas vertical p, JST-XH coudés au dos.
Usage : python3 pcb/gen_barrette.py [outdir]
Sortie : outdir/barrette-28b/barrette-28b.kicad_pcb (+ gerbers/ si --gerbers)
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pcbnew # noqa: E402
from params import Geom # noqa: E402
from pcb.kicad_helpers import Board, fill_and_save, export_gerbers # noqa: E402
X_GND = 3.5
X_5V = 16.5
X_C = 10.0 # axe des LED
RAIL_W = 2.0
STUB_W = 0.5
DATA_W = 0.3
def build_barrette(g: Geom, outdir: str) -> str:
L = g.barrette_long
W = g.barrette_larg
p = g.pas_rang
a = g.marge_barrette
bd = Board()
bd.rect_edge(0, 0, W, L)
# --- connecteurs JST-XH coudés, face arrière -----------------------------
# J1 = IN en bas (pads vers +Y depuis le pad 1, boîtier vers +X, pins à x = 5)
j1 = bd.footprint("Connector_JST", "JST_XH_S3B-XH-A_1x03_P2.50mm_Horizontal", "J1", 5, L - 8,
rot=270, back=True, value="IN 5V-DATA-GND")
bd.place_by_pad(j1, "1", 5, L - 8) # pad2 (5, L-5.5), pad3 (5, L-3)
# J2 = OUT en haut (pads vers −Y depuis le pad 1, boîtier vers −X, pins à x = 15)
j2 = bd.footprint("Connector_JST", "JST_XH_S3B-XH-A_1x03_P2.50mm_Horizontal", "J2", 15, 8,
rot=90, back=True, value="OUT 5V-DATA-GND")
bd.place_by_pad(j2, "1", 15, 8) # pad2 (15, 5.5), pad3 (15, 3)
for j in (j1, j2):
bd.connect(j, "1", "+5V"); bd.connect(j, "3", "GND")
bd.connect(j1, "2", "DIN")
bd.connect(j2, "2", f"D{g.n_rang}")
# --- résistance série 330R sur DATA IN ------------------------------------
r1 = bd.footprint("Resistor_SMD", "R_0805_2012Metric", "R1", 10, L - 5.5, value="330R")
bd.connect(r1, "1", "DIN"); bd.connect(r1, "2", "D0") # pad1 (9.05) côté J1, pad2 (10.95) vers LED1
bd.track([bd.pad_xy(j1, "2"), bd.pad_xy(r1, "1")], DATA_W, "DIN") # y = L-5.5, s'arrête au pad 1
# --- rails ----------------------------------------------------------------
y_top = a - 1.65 # VSS de LED20 (haut)
bd.track([(X_5V, 8.0), (X_5V, L - 2.0)], RAIL_W, "+5V")
bd.track([(X_GND, y_top), (X_GND, L - a - 1.65)], RAIL_W, "GND")
bd.track([bd.pad_xy(j2, "1"), (X_5V, 8.0)], STUB_W, "+5V") # J2 pad1 → rail
bd.track([bd.pad_xy(j1, "1"), (X_5V, L - 8.0)], STUB_W, "+5V", layer=pcbnew.B_Cu) # J1 pad1 → via, sous la zone GND
bd.via(X_5V, L - 8.0, "+5V")
# --- LED + condensateurs ----------------------------------------------------
leds = []
for k in range(1, g.n_rang + 1):
y = L - a - (k - 1) * p
d = bd.footprint("LED_SMD", "LED_WS2812B_PLCC4_5.0x5.0mm_P3.2mm", f"D{k}", X_C, y, rot=180, value="WS2812B")
bd.connect(d, "1", "+5V"); bd.connect(d, "3", "GND")
bd.connect(d, "4", f"D{k-1}"); bd.connect(d, "2", f"D{k}")
p1 = bd.pad_xy(d, "1"); p3 = bd.pad_xy(d, "3")
bd.track([p1, (X_5V, p1[1])], STUB_W, "+5V")
bd.track([p3, (X_GND, p3[1])], STUB_W, "GND")
if k % 4 == 0:
bd.via(X_GND, p3[1] - 3.0, "GND")
leds.append(d)
# condensateur : entre cette LED et la suivante (au-dessus), côté +5V.
# rot=90 → pad 2 en haut (+5V, stub vers le rail), pad 1 en bas (GND, via vers le bas).
# C20 : coin bas-droite à côté de R1, rot=270 pour que le via GND parte vers le haut (pas vers le bord).
if k < g.n_rang:
yc, rot = y - p / 2, 90
else:
yc, rot = L - 3.0, 270
c = bd.footprint("Capacitor_SMD", "C_0805_2012Metric", f"C{k}", 13.5, yc, rot=rot, value="100nF")
bd.connect(c, "2", "+5V"); bd.connect(c, "1", "GND")
c1 = bd.pad_xy(c, "1"); c2 = bd.pad_xy(c, "2")
bd.track([c2, (X_5V, c2[1])], 0.4, "+5V")
vy = c1[1] - 1.6 if c1[1] < c2[1] else c1[1] + 1.6
bd.track([c1, (c1[0], vy)], 0.4, "GND")
bd.via(c1[0], vy, "GND")
# --- chaîne data -------------------------------------------------------------
bd.track([bd.pad_xy(r1, "2"), bd.pad_xy(leds[0], "4")], DATA_W, "D0")
for k in range(g.n_rang - 1):
bd.track([bd.pad_xy(leds[k], "2"), bd.pad_xy(leds[k + 1], "4")], DATA_W, f"D{k+1}")
bd.track([bd.pad_xy(leds[-1], "2"), bd.pad_xy(j2, "2")], DATA_W, f"D{g.n_rang}")
# --- zone GND dessous ----------------------------------------------------------
bd.zone(pcbnew.B_Cu, "GND", [(0, 0), (W, 0), (W, L), (0, L)])
# --- sérigraphie ----------------------------------------------------------------
bd.text("IN", 10, L - 2.0, size=1.2)
bd.text("OUT", 10, 2.0, size=1.2)
bd.text("^ DATA ^", 10, L / 2, size=1.0, rot=90)
bd.text(f"28B-COL v1 pas {p:.2f}", 10, L / 2 + 25, size=0.8, rot=90)
for pad, lab in (("1", "5V"), ("2", "D"), ("3", "G")):
x, y = bd.pad_xy(j1, pad); bd.text(lab, x + 2.2, y, layer=pcbnew.B_SilkS, size=0.7)
x, y = bd.pad_xy(j2, pad); bd.text(lab, x - 2.2, y, layer=pcbnew.B_SilkS, size=0.7)
for k, d in enumerate(leds, start=1):
x, y = bd.pad_xy(d, "4")
bd.text(str(k), X_C, y + 2.6, size=0.7)
path = bd.save(os.path.join(outdir, "barrette-28b", "barrette-28b.kicad_pcb"))
fill_and_save(path)
return path
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else "out/pcb"
pth = build_barrette(Geom(), out)
print(pth)
if "--gerbers" in sys.argv:
for f in export_gerbers(pth, os.path.join(os.path.dirname(pth), "gerbers")):
print(f)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_barrette.py -q -x 2>&1 | tail -15
Expected: 5 passed. Si test_drc_clean échoue, lire le rapport : cat /tmp/pytest-of-root/pytest-current/barrette*/barrette-28b/barrette-28b-drc.txt | grep -B1 -A3 "Severity: error" | head -40. Causes typiques et correctifs :
- clearance entre le stub VDD d'une LED et le pad d'un condensateur → décaler yc de 0,5 mm (y - p/2 - 0.5).
- clearance entre la piste data et une piste de stub → augmenter l'écart en passant le stub concerné à y ± 0,3.
- courtyards_overlap → vérifier que J1/J2 sont bien back=True et que C20 à (13.5, L - 3) ne touche pas R1 (courtyard de R1 : x 8,5-11,5) ; au besoin C20 à (14.0, L - 3).
- via GND de C20 qui part vers le bord → vérifier rot=270 pour C20 (pad 1 en haut).
- unconnected_items sur +5V/GND → un stub ne touche pas le rail : vérifier que X_5V/X_GND sont bien dans l'intervalle des y des rails (8 … L-4 et y_top … L-a-1.65).
Corriger, relancer jusqu'à 5 passed.
Run:
cd /home/claude/spectrum && python3 pcb/gen_barrette.py /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest --gerbers | tail -1 && kicad-cli pcb export pdf --output /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F.pdf --layers F.Cu,F.SilkS,Edge.Cuts /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-28b/barrette-28b.kicad_pcb && pdftoppm -r 80 -png /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F.pdf /tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest/barrette-F
Ouvrir le PNG avec Read (c'est une barrette de 390 × 20 : zoomer avec convert -crop sur les deux bouts si nécessaire). Vérifier : les deux JST dans la carte, les 20 LED alignées, les diagonales data qui ne touchent rien, « IN » en bas, « OUT » en haut.
cd /home/claude/spectrum && git add -A && git commit -qm "feat: barrette LED 28B-COL (KiCad), DRC propre, export Gerbers" && git log --oneline | head -1
Files:
- Create: pcb/gen_backplane.py
- Create: tests/test_pcb_backplane.py
Interfaces:
- Consumes: params.Geom, pcb.kicad_helpers.*
- Produces: build_backplane(g, outdir) -> str, constante JUMPERS = ["JA0", "JB0", "JA1", "JB1", "JA2", "JB2", "JA3", "JB3"] et dict JUMPER_NETS (nom → (net pad1, net pad2)).
Patron A (segments 1 et 3) : fermer JA0 (INB–D1), JA1 (D2–D3), JA2 (D4–D5), JA3 (D6–D7). Patron B (segments 2 et 4) : fermer JB0 (D1–D2), JB1 (D3–D4), JB2 (D5–D6), JB3 (D7–OUT). JBYP (IN–INB) fermé si U1 absent.
Créer tests/test_pcb_backplane.py :
import os
import pcbnew
import pytest
from params import Geom
from pcb.kicad_helpers import run_drc, export_gerbers, tomm
from pcb.gen_backplane import build_backplane, JUMPER_NETS
@pytest.fixture(scope="module")
def board_path(tmp_path_factory):
return build_backplane(Geom(), str(tmp_path_factory.mktemp("backplane")))
def test_headers_positions_and_nets(board_path):
b = pcbnew.LoadBoard(board_path)
g = Geom()
fps = {fp.GetReference(): fp for fp in b.GetFootprints()}
for k in range(1, 8):
h = fps[f"H{k}"]
x, y = tomm(h.FindPadByNumber("2").GetPosition())
assert x == pytest.approx(22.52 + (k - 1) * g.pas_col, abs=0.05)
assert y == pytest.approx(10.0)
assert h.FindPadByNumber("1").GetNetname() == "+5V"
assert h.FindPadByNumber("2").GetNetname() == f"D{k}"
assert h.FindPadByNumber("3").GetNetname() == "GND"
assert fps["JIN"].FindPadByNumber("2").GetNetname() == "IN"
assert fps["JOUT"].FindPadByNumber("2").GetNetname() == "OUT"
assert fps["U1"].FindPadByNumber("2").GetNetname() == "IN"
assert fps["U1"].FindPadByNumber("4").GetNetname() == "INB"
assert fps["U1"].FindPadByNumber("1").GetNetname() == "GND"
assert fps["U1"].FindPadByNumber("5").GetNetname() == "+5V"
for ref, (n1, n2) in JUMPER_NETS.items():
assert fps[ref].FindPadByNumber("1").GetNetname() == n1, ref
assert fps[ref].FindPadByNumber("2").GetNetname() == n2, ref
assert sum(r.startswith("MH") for r in fps) == 4
bb = b.GetBoardEdgesBoundingBox()
assert pcbnew.ToMM(bb.GetWidth()) == pytest.approx(7 * g.pas_col - 1.0, abs=0.2)
assert pcbnew.ToMM(bb.GetHeight()) == pytest.approx(30.0, abs=0.2)
def test_drc_clean(board_path):
errors, unconnected = run_drc(board_path)
assert unconnected == 0
assert errors == 0
def test_gerbers(board_path):
files = export_gerbers(board_path, os.path.join(os.path.dirname(board_path), "gerbers"))
assert any(f.endswith(".drl") for f in files)
assert any("B_Cu" in f for f in files)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_backplane.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'pcb.gen_backplane'
pcb/gen_backplane.py"""Backplane 28B-BP : 1 segment de 7 colonnes (4 identiques par tour).
Usage : python3 pcb/gen_backplane.py [outdir] [--gerbers]
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pcbnew # noqa: E402
from params import Geom # noqa: E402
from pcb.kicad_helpers import Board, fill_and_save, export_gerbers # noqa: E402
H = 30.0
Y_HDR = 10.0 # axe des broches des prises
Y_JMP = 19.0 # axe des cavaliers
JUMPERS = ["JA0", "JB0", "JA1", "JB1", "JA2", "JB2", "JA3", "JB3"]
JUMPER_NETS = {
"JA0": ("INB", "D1"), "JB0": ("D1", "D2"),
"JA1": ("D2", "D3"), "JB1": ("D3", "D4"),
"JA2": ("D4", "D5"), "JB2": ("D5", "D6"),
"JA3": ("D6", "D7"), "JB3": ("D7", "OUT"),
"JBYP": ("IN", "INB"),
}
JMP_LIB = ("Jumper", "SolderJumper-2_P1.3mm_Open_RoundedPad1.0x1.5mm")
def build_backplane(g: Geom, outdir: str) -> str:
W = 7 * g.pas_col - 1.0
x_h = [22.52 + k * g.pas_col for k in range(7)] # pad 2 (DATA) des prises
bd = Board()
bd.rect_edge(0, 0, W, H)
# --- prises JST verticales --------------------------------------------------
hdr = []
for k, x in enumerate(x_h, start=1):
h = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", f"H{k}", x, Y_HDR, value=f"COL {k}")
bd.place_by_pad(h, "2", x, Y_HDR)
bd.connect(h, "1", "+5V"); bd.connect(h, "2", f"D{k}"); bd.connect(h, "3", "GND")
hdr.append(h)
jin = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", "JIN", 7.5, Y_HDR, value="IN")
bd.place_by_pad(jin, "2", 7.5, Y_HDR)
jout = bd.footprint("Connector_JST", "JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical", "JOUT", W - 7.5, Y_HDR, value="OUT")
bd.place_by_pad(jout, "2", W - 7.5, Y_HDR)
for j, net in ((jin, "IN"), (jout, "OUT")):
bd.connect(j, "1", "+5V"); bd.connect(j, "2", net); bd.connect(j, "3", "GND")
# --- cavaliers entre les prises -----------------------------------------------
jm = {}
xs_j = [15.0] + [(x_h[i] + x_h[i + 1]) / 2 for i in range(6)] + [W - 15.0]
for name, x in zip(JUMPERS, xs_j):
j = bd.footprint(*JMP_LIB, name, x, Y_JMP, value=name)
n1, n2 = JUMPER_NETS[name]
bd.connect(j, "1", n1); bd.connect(j, "2", n2)
jm[name] = j
# descentes D_k : prise → ligne des cavaliers, puis segments horizontaux vers les cavaliers voisins
for k, (h, x) in enumerate(zip(hdr, x_h), start=1):
bd.track([bd.pad_xy(h, "2"), (x, Y_JMP)], 0.3, f"D{k}")
left, right = JUMPERS[k - 1], JUMPERS[k] # cavalier à gauche / à droite de la prise k
bd.track([(x, Y_JMP), bd.pad_xy(jm[left], "2")], 0.3, f"D{k}")
bd.track([(x, Y_JMP), bd.pad_xy(jm[right], "1")], 0.3, f"D{k}")
# OUT : JB3 pad2 → prise OUT
bd.track([bd.pad_xy(jm["JB3"], "2"), (W - 7.5, Y_JMP), bd.pad_xy(jout, "2")], 0.3, "OUT")
# --- tampon optionnel 74AHCT1G125 + contournement ----------------------------------
u1 = bd.footprint("Package_TO_SOT_SMD", "SOT-23-5", "U1", 12.0, 26.0, value="SN74AHCT1G125 (option)")
bd.connect(u1, "1", "GND"); bd.connect(u1, "2", "IN"); bd.connect(u1, "3", "GND")
bd.connect(u1, "4", "INB"); bd.connect(u1, "5", "+5V")
c1 = bd.footprint("Capacitor_SMD", "C_0805_2012Metric", "C1", 19.5, 27.5, value="100nF") # à droite du trajet INB (x = 16,5)
bd.connect(c1, "1", "+5V"); bd.connect(c1, "2", "GND")
jbyp = bd.footprint(*JMP_LIB, "JBYP", 15.0, 22.5, value="JBYP")
bd.connect(jbyp, "1", "IN"); bd.connect(jbyp, "2", "INB")
# IN : prise IN → U1.A et → JBYP.1
bd.track([bd.pad_xy(jin, "2"), (7.5, 26.0), bd.pad_xy(u1, "2")], 0.3, "IN")
bd.track([(7.5, 22.5), bd.pad_xy(jbyp, "1")], 0.3, "IN")
# INB : U1.Y → JBYP.2 → JA0.1
y4 = bd.pad_xy(u1, "4")
bd.track([y4, (16.5, y4[1]), (16.5, 23.5), bd.pad_xy(jbyp, "2")], 0.3, "INB")
bd.track([bd.pad_xy(jbyp, "2"), (15.65, 21.0), (14.35, 19.9), bd.pad_xy(jm["JA0"], "1")], 0.3, "INB")
# GND de U1 (pads 1 et 3) par vias ; +5V par la zone ; C1 pad2 par via
p1 = bd.pad_xy(u1, "1"); p3 = bd.pad_xy(u1, "3")
bd.track([p1, (p1[0], p1[1] - 1.4)], 0.3, "GND"); bd.via(p1[0], p1[1] - 1.4, "GND") # via à y ≈ 23,65, 0,6 sous la piste IN (y = 22,5)
bd.track([p3, (p3[0], p3[1] + 1.8)], 0.3, "GND"); bd.via(p3[0], p3[1] + 1.8, "GND") # via à y ≈ 28,75, 0,85 du bord
c2 = bd.pad_xy(c1, "2")
bd.track([c2, (c2[0] + 1.6, c2[1])], 0.3, "GND"); bd.via(c2[0] + 1.6, c2[1], "GND")
# --- alimentation : bornier + 1000 µF --------------------------------------------------
tb = bd.footprint("TerminalBlock", "TerminalBlock_bornier-2_P5.08mm", "TB1", W / 2 - 2.54, 25.0, value="5V GND 16AWG")
bd.connect(tb, "1", "+5V"); bd.connect(tb, "2", "GND")
cp = bd.footprint("Capacitor_THT", "CP_Radial_D10.0mm_P5.00mm", "C2", W / 2 + 15.0, 24.0, value="1000uF 10V")
bd.connect(cp, "1", "+5V"); bd.connect(cp, "2", "GND")
# --- trous de montage M3 ------------------------------------------------------------------
for i, (x, y) in enumerate([(5.0, 4.0), (W - 5.0, 4.0), (W - 5.0, 26.0), (W / 2 - 25.0, 26.0)], start=1):
bd.footprint("MountingHole", "MountingHole_3.2mm_M3", f"MH{i}", x, y)
# --- zones : +5V dessus, GND dessous -----------------------------------------------------------
bd.zone(pcbnew.F_Cu, "+5V", [(0, 0), (W, 0), (W, H), (0, H)])
bd.zone(pcbnew.B_Cu, "GND", [(0, 0), (W, 0), (W, H), (0, H)])
# --- sérigraphie ----------------------------------------------------------------------------------
bd.text("28B-BP v1 patron A: JA0-JA3 (seg 1,3) patron B: JB0-JB3 (seg 2,4) JBYP ferme si U1 absent", W / 2, 2.0, size=0.9)
for k, x in enumerate(x_h, start=1):
bd.text(f"C{k}", x, Y_HDR + 5.0, size=0.8)
bd.text("IN", 7.5, 15.5, size=0.9); bd.text("OUT", W - 7.5, 15.5, size=0.9)
bd.text("5V GND", W / 2, 29.0, size=0.8)
path = bd.save(os.path.join(outdir, "backplane-28b", "backplane-28b.kicad_pcb"))
fill_and_save(path)
return path
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else "out/pcb"
pth = build_backplane(Geom(), out)
print(pth)
if "--gerbers" in sys.argv:
for f in export_gerbers(pth, os.path.join(os.path.dirname(pth), "gerbers")):
print(f)
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_pcb_backplane.py -q -x 2>&1 | tail -15
Expected: 3 passed. Si le DRC échoue : grep -B1 -A3 "Severity: error" /tmp/pytest-of-root/pytest-current/backplane*/backplane-28b/backplane-28b-drc.txt | head -40. Correctifs typiques : un dégagement entre la piste IN à y = 22,5 et la piste INB → passer la verticale INB de x = 16,5 à 17,0 ; cavalier JA0 trop près de la prise H1 (courtyard) → JA0 à x = 14,0 ; bornier/condensateur qui touchent le trou MH4 → MH4 à (W/2 - 30, 26).
Run:
cd /home/claude/spectrum && S=/tmp/claude-0/-home-claude/85a4e819-e409-5b30-b8b9-0b01549ae114/scratchpad/pcbtest && python3 pcb/gen_backplane.py $S --gerbers | tail -1 && kicad-cli pcb export pdf --output $S/bp-F.pdf --layers F.Cu,F.SilkS,Edge.Cuts $S/backplane-28b/backplane-28b.kicad_pcb && pdftoppm -r 80 -png $S/bp-F.pdf $S/bp-F
Ouvrir le PNG avec Read. Vérifier les 7 prises, les 8 cavaliers alternés A/B, IN à gauche, OUT à droite.
cd /home/claude/spectrum && git add -A && git commit -qm "feat: backplane 28B-BP (KiCad), DRC propre, export Gerbers" && git log --oneline | head -1
Files:
- Create: docs/gen_bom.py
- Create: docs/GUIDE-ASSEMBLAGE.md
- Create: tests/test_bom.py
Interfaces:
- Consumes: params.Geom
- Produces: bom_rows(g) -> list[dict] (clés poste, qte, unite, detail, fournisseur, prix_cad), write_bom(g, md_path, csv_path) -> None.
Créer tests/test_bom.py :
import csv
from params import Geom
from docs.gen_bom import bom_rows, write_bom
def test_quantities_follow_geom():
g = Geom()
rows = {r["poste"]: r for r in bom_rows(g)}
assert rows["Rondelles plates 1/4 po SAE zinguées (un seul lot)"]["qte"] == 5000
assert rows["Tiges filetées 1/4-20 x 36 po zinguées"]["qte"] == 28
assert rows["Écrous nylock 1/4-20"]["qte"] == 56
assert rows["Écrous borgnes 1/4-20"]["qte"] == 56
assert rows["WS2812B-5050 (5 V)"]["qte"] == 630
assert rows["PCB barrette 28B-COL (PCBWay)"]["qte"] == 30
assert rows["PCB backplane 28B-BP (PCBWay)"]["qte"] == 5
assert rows["Bandes d'acrylique 2 1/2 po x 96 po (déjà en main)"]["qte"] == g.bandes_necessaires
assert rows["Câbles JST-XH 3 fils femelle-femelle 15 cm"]["qte"] == 28
assert rows["Câbles JST-XH 3 fils femelle-femelle 5 cm"]["qte"] == 14
def test_write_bom(tmp_path):
g = Geom()
md = tmp_path / "BOM.md"; cs = tmp_path / "BOM.csv"
write_bom(g, str(md), str(cs))
t = md.read_text(encoding="utf-8")
assert "| Poste |" in t and "Rondelles" in t and "Total" in t
with open(cs, encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) >= 30
assert set(rows[0].keys()) == {"poste", "qte", "unite", "detail", "fournisseur", "prix_cad"}
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_bom.py -q 2>&1 | tail -3
Expected: ModuleNotFoundError: No module named 'docs.gen_bom' (créer aussi docs/__init__.py vide à l'étape suivante).
docs/gen_bom.py"""BOM globale du Spectrum 28 bandes, quantités calculées depuis Geom.
Usage : python3 docs/gen_bom.py out/BOM.md out/BOM.csv
Les prix sont des ordres de grandeur en CAD (2026), à confirmer à l'achat.
"""
from __future__ import annotations
import csv
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from params import Geom # noqa: E402
def _r(poste, qte, unite, detail, fournisseur, prix):
return {"poste": poste, "qte": qte, "unite": unite, "detail": detail, "fournisseur": fournisseur, "prix_cad": prix}
def bom_rows(g: Geom) -> list[dict]:
n_led = g.n_col * g.n_rang + 70 # 560 + rechange pour 30 barrettes
return [
# --- déjà en main ---
_r("Bandes d'acrylique 2 1/2 po x 96 po (déjà en main)", g.bandes_necessaires, "bandes",
f"{g.blocs_par_bande} blocs par bande, {g.n_blocs} blocs + {g.rechange_blocs} de rechange", "Industries FM (déjà acheté)", 0),
_r("Barres aluminium 1/8 x 5 x ~60 po (déjà en main)", 4, "barres",
"1 dessus, 1 dessous, 1 refendue 3 po (barre du haut), 1 refendue 2 x 2 1/2 po (faces)", "déjà acheté", 0),
_r("Carte PLATINUM ESP32 28 bandes (déjà en main)", 1, "carte", "4x MSGEQ7 + 2x Si5351, borne LEDS", "déjà acheté", 0),
# --- structure ---
_r("Tiges filetées 1/4-20 x 36 po zinguées", g.n_tiges // 2, "tiges", f"coupées en 2 x {g.longueur_tige:.0f} mm (17 po)", "Home Depot / Canadian Tire / Fastenal", 110),
_r("Écrous nylock 1/4-20", g.n_tiges, "pcs", "sous la plaque de base", "Fastenal / Amazon.ca", 10),
_r("Écrous borgnes 1/4-20", g.n_tiges, "pcs", "dessus la barre du haut (look référence)", "Fastenal / Amazon.ca", 25),
_r("Rondelles plates 1/4 po SAE zinguées (un seul lot)", 5000, "pcs",
f"OD 5/8 po, {g.n_rondelles} nécessaires ; mesurer une pile de 40 avant le PCB", "Fastenal / Spaenaur / Amazon.ca (5 lb)", 150),
_r("Cornière aluminium 1/2 x 1/2 x 1/16 po x 6 pi", 2, "pcs", "arêtes de la boîte de base", "Home Depot / Metal Supermarkets", 25),
_r("Rivets pop 3/16 alu + vis #6 / M4", 1, "lot", "assemblage de la boîte, dessous vissé", "Home Depot", 20),
_r("Acrylique 1/2 po pour bouts de la base (retailles)", 2, "pcs", "2 1/2 x 4 3/4 po, peints noir", "retailles en main", 0),
_r("Ruban mousse double face acrylique 2 mm (VHB ou équiv.)", 25, "m", "en 6 mm de large, 2 bandes par barrette", "Amazon.ca / Uline", 40),
_r("Pieds caoutchouc, peinture noire mate, alcool isopropylique", 1, "lot", "", "Canadian Tire", 30),
# --- outillage ---
_r("Mèche 9/32 po et 5/16 po (plastique ou réaffûtée 0°), mèche étagée 1/2 po, lame 80 dents", 1, "lot", "", "Home Depot / Amazon.ca", 60),
# --- PCB ---
_r("PCB barrette 28B-COL (PCBWay)", 30, "pcs", f"{g.barrette_long:.1f} x {g.barrette_larg:.0f} mm, 2 couches, masque noir, HASL-LF", "PCBWay", 90),
_r("PCB backplane 28B-BP (PCBWay)", 5, "pcs", "321.3 x 30 mm, 2 couches, masque noir", "PCBWay", 30),
_r("Stencil acier sans cadre pour la barrette (dessus)", 1, "pcs", "commandé avec les PCB", "PCBWay", 25),
_r("Transport PCBWay", 1, "", "DHL/FedEx vers le Québec", "PCBWay", 50),
# --- composants ---
_r("WS2812B-5050 (5 V)", n_led, "pcs", "boîtier 5050 4 pads, bobine", "LCSC / DigiKey Canada / AliExpress", 45),
_r("Condensateur 100 nF 0805 X7R 16 V", n_led, "pcs", "1 par LED + U1 backplane", "LCSC / DigiKey Canada", 6),
_r("Résistance 330 ohm 0805", 30, "pcs", "DATA IN de chaque barrette", "LCSC / DigiKey Canada", 2),
_r("JST S3B-XH-A (XH 3 broches coudé)", 60, "pcs", "2 par barrette, face arrière", "LCSC / DigiKey Canada", 12),
_r("JST B3B-XH-A (XH 3 broches vertical)", 40, "pcs", "9 par backplane (7 + IN + OUT), 4 backplanes", "LCSC / DigiKey Canada", 8),
_r("Bornier à vis 2 pôles 5,08 mm", 4, "pcs", "injection 5 V/GND par segment", "LCSC / DigiKey Canada", 4),
_r("Condensateur 1000 uF 10 V radial D10 P5", 4, "pcs", "1 par segment", "DigiKey Canada", 4),
_r("SN74AHCT1G125DBVR (SOT-23-5)", 4, "pcs", "tampon optionnel, 1 par segment", "DigiKey Canada", 4),
_r("Câbles JST-XH 3 fils femelle-femelle 15 cm", g.n_col, "pcs", "barrette → backplane", "Amazon.ca / AliExpress (pré-sertis)", 20),
_r("Câbles JST-XH 3 fils femelle-femelle 5 cm", g.n_col // 2, "pcs", "liens du haut (1→2, 3→4 ...)", "Amazon.ca / AliExpress", 10),
_r("Câbles JST-XH 3 fils 10 cm (segments 2→3) + 2 fils carte → IN", 2, "pcs", "", "Amazon.ca", 5),
_r("Pâte à souder Sn-Bi basse température (138 °C) + flux", 1, "lot", "refusion 165-180 °C, plaque chauffante", "Amazon.ca / DigiKey Canada", 30),
# --- alimentation ---
_r("Adaptateur 5 V 30 A (150 W) de marque, sortie fil nu", 1, "pcs", "20 A minimum ; fusible 30 A en ligne", "Amazon.ca / DigiKey Canada (Mean Well)", 70),
_r("XT60 panneau femelle + XT60 mâle + porte-fusible 30 A", 1, "lot", "", "Amazon.ca", 12),
_r("Fil 16 AWG rouge/noir (5 m) + 18 AWG + WAGO 221-415 (5)", 1, "lot", "distribution dans la base", "Home Depot / Amazon.ca", 25),
]
def write_bom(g: Geom, md_path: str, csv_path: str) -> None:
rows = bom_rows(g)
total = sum(r["prix_cad"] for r in rows)
os.makedirs(os.path.dirname(md_path) or ".", exist_ok=True)
with open(md_path, "w", encoding="utf-8") as f:
f.write("# BOM — Spectrum 28 bandes\n\n")
f.write(f"Générée depuis `params.py` (t_rondelle = {g.t_rondelle} mm, pas vertical = {g.pas_rang:.2f} mm, "
f"l_barre = {g.l_barre:.0f} mm). Prix approximatifs en CAD, à confirmer.\n\n")
f.write("| Poste | Qté | Unité | Détail | Fournisseur | Prix approx. (CAD) |\n|---|---|---|---|---|---|\n")
for r in rows:
f.write(f"| {r['poste']} | {r['qte']} | {r['unite']} | {r['detail']} | {r['fournisseur']} | {r['prix_cad']} |\n")
f.write(f"| **Total (hors ce qui est déjà en main)** | | | | | **≈ {total} $** |\n")
with open(csv_path, "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=["poste", "qte", "unite", "detail", "fournisseur", "prix_cad"])
w.writeheader()
w.writerows(rows)
if __name__ == "__main__":
write_bom(Geom(), sys.argv[1] if len(sys.argv) > 1 else "out/BOM.md",
sys.argv[2] if len(sys.argv) > 2 else "out/BOM.csv")
Créer docs/__init__.py vide : touch docs/__init__.py.
Run: cd /home/claude/spectrum && python3 -m pytest tests/test_bom.py -q 2>&1 | tail -3
Expected: 2 passed
docs/GUIDE-ASSEMBLAGE.md# Guide d'assemblage — Spectrum 28 bandes
Référence : spec `docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md`. Dessins dans `out/`. Toutes les cotes en mm sauf mention.
## 0. Avant tout : les trois mesures
1. **Barres d'aluminium** : mesurer la longueur réelle des 4 barres. Mettre la valeur dans `params.py` (`l_barre`). Si < 1481, la marge de la zone tour passe à 10 ; si < 1451, la fente entre blocs passe à ¼ po. `./build.sh` régénère tout.
2. **Rondelles** : acheter les 5000 rondelles ¼ po SAE d'un seul lot. Empiler 40 rondelles, mesurer la pile au vernier, diviser par 40 → `t_rondelle`. Régénérer. Le pas vertical des LED sur la barrette dépend de cette valeur : **ne pas commander les PCB avant**.
3. **Carte PLATINUM** : mesurer le diamètre des bagues des 4 pots et de l'interrupteur, et leur entraxe → `d_bague_pot`, `d_bague_inter`, `pas_pots`.
## 1. Test de sens et de signal (10 min, avant la commande PCB)
Brancher un bout de ruban WS2812B (≥ 40 LED) sur la borne LEDS de la carte (5 V, DATA, GND). Jouer une toune avec des basses. Noter : la LED 0 et les LED 0-19 forment la colonne 1 ; les LED 20-39 la colonne 2 en sens inverse. Si la colonne 1 réagit aux basses → la chaîne part à gauche (segments 1 et 3 en patron A, 2 et 4 en patron B). Si elle réagit aux aigus et qu'on veut les basses à gauche → la chaîne part à droite : on inverse l'ordre des segments, on les tourne de 180°, et on inverse les patrons. Aucun PCB à changer. Si une LED scintille : prévoir U1 (74AHCT) sur le backplane.
## 2. Commande PCB (PCBWay)
- `out/pcb/barrette-28b/gerbers/` → 30 pcs, 2 couches, 1,6 mm, 1 oz, masque **noir**, sérigraphie blanche, HASL sans plomb, + **stencil dessus** sans cadre.
- `out/pcb/backplane-28b/gerbers/` → 5 pcs, mêmes options, pas de stencil (soudure au fer).
- Ouvrir les `.kicad_pcb` dans KiCad (7, 8 ou 9) pour vérifier avant d'envoyer. Les fichiers Gerber + `.drl` + `.gbrjob` se zippent tels quels.
- Composants : voir `out/BOM.md`.
## 3. Blocs (560 + rechange)
1. Table saw, lame 80 dents, butée de longueur à 38,1 (1½ po). Mesurer les 5 premiers blocs au vernier.
2. Perçage : gabarit `out/dessins-bloc-gabarit.pdf` page 2. Butée arrière + butée latérale, mèche 9/32, basse vitesse, martyr MDF. Percer un trou, tourner le bloc de 180°, percer l'autre.
3. **Essai** : 60 blocs, 3 colonnes enfilées (4 rondelles, bloc, 4 rondelles…). Mesurer la hauteur de 20 rangées : doit être à ±1 mm de 19 × pas. Si ça coince : mèche 5/16.
4. Faire le reste. Optionnel : poncer dessus/dessous au 220-320 pour le look givré (tester sur 3 blocs).
## 4. Base en aluminium
1. Refendre la barre 3 à 3 po (barre du haut) et la barre 4 en 2 × 2½ po (faces avant/arrière). Lame carbure non-ferreux ou faire faire.
2. Imprimer `out/gabarit-1to1-plaque.pdf` à **100 %**, vérifier la barre de 100 mm à la règle, coller les pages bord à bord sur les traits pointillés.
3. Coller le gabarit sur la plaque du dessus ; serrer la barre du haut dessous, alignée sur le bord avant des blocs (y = 20 du gabarit = bord avant de la barre). Pointer et percer les 56 trous de tiges (5/16) à travers les deux pièces. Puis, plaque seule : 28 trous de câbles (½ po, mèche étagée) et les trous de contrôle.
4. Assembler la boîte : cornières ½ po rivetées le long des arêtes, dessus et faces rivetés, dessous vissé. Bouts en acrylique ½ po peints.
5. Face arrière : découpe XT60, trous jacks/DC de la carte, accès USB-C. Peinture si désiré.
6. Monter la carte PLATINUM sous le dessus par les écrous de ses pots. Backplanes sur entretoises M3 sous les trous de câbles. XT60 → porte-fusible → WAGO → 4 paires 16 AWG vers les borniers + 18 AWG vers le jack DC de la carte. DATA + GND de la borne LEDS vers IN du segment 1 (torsadés).
## 5. Barrettes
1. 3 barrettes d'abord : stencil, pâte Sn-Bi, poser 20 LED + 20 condos (attention au repère du pad 1 de la LED, coin coupé), refusion 165-180 °C. Souder R1 et les 2 JST au fer.
2. Tester sur la carte avec un câble JST : les 20 LED répondent. Puis les 25 autres (+2 rechange).
3. Cavaliers des backplanes : segments 1 et 3 → fermer JA0, JA1, JA2, JA3 ; segments 2 et 4 → JB0, JB1, JB2, JB3. JBYP fermé si U1 absent.
## 6. Tour
1. 56 tiges de 17 po : écrou nylock + rondelle, passer par-dessous la plaque.
2. Par colonne : 4 rondelles, bloc, 4 rondelles, … bloc 20, 4 rondelles. Les deux tiges d'une colonne doivent recevoir le même nombre de rondelles.
3. Barre du haut, rondelle, écrou borgne sur chaque tige. Serrer à la main + ¼ de tour, en croisant.
4. Nettoyer la face arrière de chaque colonne à l'alcool. Deux bandes de ruban mousse 2 mm le long des bords de la barrette (jamais sur les LED). Coller : bout IN **en bas** pour les colonnes impaires, **en haut** pour les colonnes paires (barrette tournée de 180°). Bas de la barrette à ~1,5 mm au-dessus de la plaque. Presser 30 s, laisser 24 h avant de câbler.
5. Câbles 15 cm des bas de barrettes vers les prises C1…C7 des backplanes par les trous de câbles. Liens 5 cm en haut : 1→2, 3→4 … 27→28. Segment 2 OUT → segment 3 IN.
## 7. Tests finaux
- [ ] 5 V présent sur les 4 borniers, polarité vérifiée avant de brancher les barrettes.
- [ ] Chaîne : les 560 pixels s'allument en séquence (mode test de la carte ou musique).
- [ ] Aucune LED qui scintille ; sinon activer U1.
- [ ] Alignement : chaque LED au centre de son bloc (±2 mm). Sinon, ajuster les rondelles de la colonne.
- [ ] Réglage des pots (sensibilité, peak, amplitude, luminosité) ; limiter la luminosité si l'adaptateur chauffe.
cd /home/claude/spectrum && git add -A && git commit -qm "feat: BOM générée et guide d'assemblage" && git log --oneline | head -1
build.sh, génération de out/ et livraisonFiles:
- Create: build.sh
- Create: out/ (généré)
- Modify: docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md (statut)
Interfaces:
- Consumes: tout ce qui précède.
- Produces: out/dessins-bloc-gabarit.pdf, out/dessins-plaque-barre.pdf, out/dessins-assemblage.pdf, out/gabarit-1to1-plaque.pdf, out/dxf/{bloc,plaque-dessus,barre-haut}.dxf, out/pcb/barrette-28b/{barrette-28b.kicad_pcb,gerbers/,barrette-28b-drc.txt}, out/pcb/backplane-28b/..., out/BOM.md, out/BOM.csv, out/GUIDE-ASSEMBLAGE.md, out/spectrum-28-bandes-livrables.zip.
build.sh#!/usr/bin/env bash
# Régénère tous les livrables dans out/ à partir de params.py.
set -euo pipefail
cd "$(dirname "$0")"
rm -rf out && mkdir -p out/dxf out/pcb
python3 - <<'EOF'
from params import Geom
from drawings.dxf_parts import dxf_bloc, dxf_plaque_dessus, dxf_barre_haut
from drawings.template_1to1 import pdf_gabarit_plaque
from drawings.views import pdf_bloc_et_gabarit, pdf_plaque_et_barre, pdf_assemblage
g = Geom()
print("Geom:", f"t_rondelle={g.t_rondelle} pas_rang={g.pas_rang:.3f} l_barre={g.l_barre} zone_tour={g.zone_tour:.1f} zone_controle={g.zone_controle:.1f} barrette={g.barrette_long:.2f}")
dxf_bloc(g, "out/dxf/bloc.dxf"); dxf_plaque_dessus(g, "out/dxf/plaque-dessus.dxf"); dxf_barre_haut(g, "out/dxf/barre-haut.dxf")
pdf_gabarit_plaque(g, "out/gabarit-1to1-plaque.pdf")
pdf_bloc_et_gabarit(g, "out/dessins-bloc-gabarit.pdf"); pdf_plaque_et_barre(g, "out/dessins-plaque-barre.pdf"); pdf_assemblage(g, "out/dessins-assemblage.pdf")
EOF
python3 pcb/gen_barrette.py out/pcb --gerbers > /dev/null
python3 pcb/gen_backplane.py out/pcb --gerbers > /dev/null
python3 - <<'EOF'
from pcb.kicad_helpers import run_drc
for p in ("out/pcb/barrette-28b/barrette-28b.kicad_pcb", "out/pcb/backplane-28b/backplane-28b.kicad_pcb"):
e, u = run_drc(p); print(f"DRC {p}: {e} erreurs, {u} non connectés"); assert e == 0 and u == 0
EOF
python3 docs/gen_bom.py out/BOM.md out/BOM.csv
cp docs/GUIDE-ASSEMBLAGE.md out/
cp docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md out/SPEC-DESIGN.md
( cd out && rm -f spectrum-28-bandes-livrables.zip && zip -qr spectrum-28-bandes-livrables.zip . -x "*.zip" )
echo "OK: $(find out -type f | wc -l) fichiers dans out/"
Run: cd /home/claude/spectrum && chmod +x build.sh && python3 -m pytest -q 2>&1 | tail -3 && ./build.sh 2>&1 | tail -6
Expected: tous les tests passed, puis DRC ...: 0 erreurs, 0 non connectés × 2 et OK: N fichiers dans out/ (N ≥ 35).
Si la suite complète plante (segfault SWIG en créant deux cartes dans le même processus), lancer les fichiers de tests PCB séparément : python3 -m pytest tests/test_pcb_barrette.py -q && python3 -m pytest tests/test_pcb_backplane.py -q && python3 -m pytest tests -q --ignore=tests/test_pcb_barrette.py --ignore=tests/test_pcb_backplane.py.
Dans docs/superpowers/specs/2026-11-04-spectrum-28-bandes-design.md, remplacer la ligne Statut : validé section par section avec Yan, en attente de relecture finale par Statut : approuvé par Yan le 2026-11-04 ; livrables générés dans out/ (voir docs/superpowers/plans/2026-11-04-spectrum-28-bandes.md).
cd /home/claude/spectrum && git add -A && git commit -qm "build: génération complète des livrables dans out/" && git log --oneline | head -1
[ ] Step 5: Livrer à Yan (dossier D:\Yetii\spectrum\build\ + projet Yetii)
Copier out/ et les sources dans /mnt/user-data/outputs/spectrum-build/ (cp -r out params.py build.sh drawings pcb docs tests pytest.ini /mnt/user-data/outputs/spectrum-build/), puis SendUserFile sur /mnt/user-data/outputs/spectrum-build/out/spectrum-28-bandes-livrables.zip et sur les 4 PDF (dessins-*.pdf, gabarit-1to1-plaque.pdf).
device_commit_files avec stagedPath = chaque fichier de /mnt/user-data/outputs/spectrum-build/out/ (PDF, DXF, BOM, guide, zip, les deux .kicad_pcb et leurs dossiers gerbers/) vers D:\Yetii\spectrum\build\out\..., et les sources (params.py, build.sh, drawings/*.py, pcb/*.py, docs/gen_bom.py, tests/*.py) vers D:\Yetii\spectrum\build\... (≤ 50 fichiers par appel).Projects.project_write : spectrum/BOM.md (depuis out/BOM.md), spectrum/GUIDE-ASSEMBLAGE.md, et une note spectrum/ETAT-SPECTRUM.md (≤ 40 lignes) : état du projet, les 3 mesures attendues de Yan, comment régénérer (params.py → build.sh), emplacement des fichiers.Couverture du spec : §3 géométrie → Task 1 ; §4 blocs/gabarit → Task 4 (dessins) + guide ; §5.1-5.2 tiges/rondelles/barre du haut → Task 2 (DXF barre), Task 4, BOM ; §5.3 collage barrettes → guide + vue de côté ; §5.4 base alu → Task 2 (DXF plaque), Task 3 (gabarit 1:1), Task 4 ; §6.2 barrette → Task 5 ; §6.3 backplane → Task 6 ; §6.4-6.5 câbles/alim → BOM + guide ; §6.6 test de sens → guide §1 ; §6.7 soudure → guide §5 ; §7 BOM → Task 7 ; §8 ordre → guide ; §9 livrables → Task 8. Non couvert volontairement : dessin de la face arrière (positions des jacks à mesurer sur la carte) — mentionné dans le guide §4.5.
Placeholders : aucun TBD ; les valeurs « à mesurer » sont des paramètres nominaux explicites de Geom.
Cohérence des noms : Geom.pas_rang / pas_col / barrette_long / marge_barrette / zone_tour / l_base / trous_controle utilisés à l'identique dans les Tasks 2-8 ; Board.place_by_pad / pad_xy / track / via / zone / text / rect_edge identiques entre kicad_helpers, gen_barrette, gen_backplane ; run_drc retourne (erreurs, non_connectés) partout ; nets JST 1=+5V, 2=DATA, 3=GND identiques barrette/backplane.
Source : PLAN-LIVRABLES.md