Initial commit: Busbar Designer

Web tool for designing nickel/copper busbars over cylindrical-cell battery
packs (21700, 18650) in hex holders. Flask + build123d backend exports
STEP/DXF/SVG; vanilla JS frontend with live preview, multi-project SQLite
persistence, snapshot history.

Deploy scripts in deploy/ (proxmox-lxc.sh, install.sh, update.sh).
This commit is contained in:
wenil
2026-05-24 18:59:50 +03:00
commit d8cb0dc06d
28 changed files with 4172 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
"""Sanity tests for busbar_export — exercise STEP/DXF/SVG writers end-to-end."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from busbar_export import to_step, to_dxf, to_svg
def _payload():
return {
"extrude": False,
"busbars": [
{
"name": "P1",
"color": "#ff8800",
"strip_width": 8.0,
"pad_radius": 7.0,
"hole_radius": 5.0,
"cells": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 22.8, "y": 0.0},
{"id": 3, "x": 45.6, "y": 0.0},
],
}
],
}
def test_step_export_is_iso_10303():
data = to_step(_payload())
text = data[:200].decode("ascii", errors="ignore")
assert "ISO-10303-21" in text, f"STEP header missing, got: {text!r}"
assert len(data) > 500
def test_dxf_export_is_nonempty():
data = to_dxf(_payload())
assert b"SECTION" in data[:2000]
def test_svg_export_is_svg():
data = to_svg(_payload())
assert b"<svg" in data[:2000]
def test_extruded_step_includes_solid():
payload = _payload()
payload["extrude"] = True
payload["thickness"] = 0.2
data = to_step(payload)
assert b"MANIFOLD_SOLID_BREP" in data or b"CLOSED_SHELL" in data
def test_panel_5p_row_is_stadium():
"""5 cells in a row → collinear hull → stadium-shaped panel."""
payload = {
"busbars": [{
"name": "5P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [{"id": i + 1, "x": i * 22.8, "y": 0.0} for i in range(5)],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
assert len(data) > 500
def test_panel_grid_uses_convex_hull():
"""4-cell square → 4-vertex hull → rounded rectangle."""
payload = {
"busbars": [{
"name": "4P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 22.8, "y": 0.0},
{"id": 3, "x": 22.8, "y": 19.746},
{"id": 4, "x": 0.0, "y": 19.746},
],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_panel_single_cell_is_disc_with_hole():
payload = {
"busbars": [{
"name": "1P",
"shape": "panel",
"pad_radius": 10.0,
"hole_radius": 5.0,
"cells": [{"id": 1, "x": 0.0, "y": 0.0}],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_wire_shape_still_works():
payload = {
"busbars": [{
"name": "wire",
"shape": "wire",
"strip_width": 6.0,
"pad_radius": 7.0,
"hole_radius": 5.0,
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
}]
}
data = to_step(payload)
assert b"ISO-10303-21" in data[:200]
def test_panel_L_shape_has_no_diagonal_bridge():
"""L-shape (7 cells in column + 2 cells across top) must not bridge across
non-selected cells. The convex-hull approach would have, but neighbor-edge
must not — verify by checking that the area of the resulting face is close
to the sum of stadium-chain segments, not the area of the L's bounding box.
"""
from busbar_export import parse_payload, busbar_sketch
cells = [{"x": 0, "y": i * 19.75} for i in range(7)]
cells += [{"x": 22.8, "y": 6 * 19.75}, {"x": 45.6, "y": 6 * 19.75}]
payload = {"busbars": [{
"name": "L", "shape": "panel",
"pad_radius": 10.0, "hole_radius": 6.0,
"hole_shape": "cross", "slit_width": 1.8,
"cells": cells,
}]}
busbars, *_ = parse_payload(payload)
face = busbar_sketch(busbars[0])
area = face.area
# Pad disc area = π·10² ≈ 314 per cell, 9 cells → 2826 max if all disjoint.
# Bridges add some, holes subtract some. The L's bounding box is
# 65.6 × 138.25 ≈ 9070 — convex hull would be at least ~4500. Neighbor
# chain should be well under 3500.
assert 2000 < area < 3500, f"area {area} suggests convex-hull bridging"
def test_cross_slit_punches_through():
"""Cross slit must remove area from the panel (vs. no holes baseline)."""
from busbar_export import parse_payload, busbar_sketch
base = {
"busbars": [{
"name": "P", "shape": "panel", "pad_radius": 10.0,
"hole_radius": 6.0, "hole_shape": "cross", "slit_width": 1.8,
"cells": [{"x": 0, "y": 0}, {"x": 22.8, "y": 0}],
}]
}
with_cross, *_ = parse_payload(base)
area_cross = busbar_sketch(with_cross[0]).area
# Same payload, tiny hole — should be nearly the same area as un-punched.
base["busbars"][0]["hole_radius"] = 0.1
base["busbars"][0]["slit_width"] = 0.1
tiny, *_ = parse_payload(base)
area_tiny = busbar_sketch(tiny[0]).area
assert area_tiny > area_cross + 50, \
f"cross didn't punch much: tiny={area_tiny:.1f}, cross={area_cross:.1f}"
+90
View File
@@ -0,0 +1,90 @@
"""Sanity tests for storage.py — uses a fresh in-memory-ish DB per test."""
import os
import sys
from pathlib import Path
# Point the storage module at a tmp DB before importing it.
TMP_DB = Path(__file__).parent / "_tmp_storage.db"
os.environ["BUSBAR_DB"] = str(TMP_DB)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import storage # noqa: E402
def setup_function(_):
if TMP_DB.exists():
TMP_DB.unlink()
storage.init_db()
def teardown_module(_):
if TMP_DB.exists():
TMP_DB.unlink()
def test_project_crud():
pid = storage.create_project("test", {"cells": [{"x": 0, "y": 0}]})
assert pid > 0
p = storage.get_project(pid)
assert p["name"] == "test"
assert p["data"]["cells"][0]["x"] == 0
assert storage.update_project(pid, name="renamed")
assert storage.get_project(pid)["name"] == "renamed"
assert storage.update_project(pid, data={"cells": [], "params": {"k": 1}})
assert storage.get_project(pid)["data"]["params"]["k"] == 1
assert storage.delete_project(pid)
assert storage.get_project(pid) is None
def test_snapshot_created_and_restore():
pid = storage.create_project("p", {"v": 1})
# snapshot=True means: snapshot the CURRENT (v=1) state before applying v=2
assert storage.update_project(pid, data={"v": 2}, snapshot=True, note="step")
snaps = storage.list_snapshots(pid)
assert len(snaps) == 1
assert snaps[0]["note"] == "step"
sid = snaps[0]["id"]
snap = storage.get_snapshot(sid)
assert snap["data"]["v"] == 1 # the *prior* state
# Restore: rolls project back to v=1 (and creates another snapshot of v=2).
assert storage.restore_snapshot(sid)
assert storage.get_project(pid)["data"]["v"] == 1
assert len(storage.list_snapshots(pid)) == 2
def test_snapshot_retention_caps_at_N():
pid = storage.create_project("p", {"v": 0})
for i in range(1, storage.SNAPSHOT_RETENTION + 10):
storage.update_project(pid, data={"v": i}, snapshot=True)
snaps = storage.list_snapshots(pid)
assert len(snaps) == storage.SNAPSHOT_RETENTION
def test_preset_crud_and_name_unique():
pid = storage.create_preset("21700", {"cellDia": 21.2})
assert pid > 0
assert storage.create_preset("21700", {"x": 1}) is None # name collision
assert storage.list_presets()[0]["params"]["cellDia"] == 21.2
assert storage.update_preset(pid, params={"cellDia": 21.4})
assert storage.get_preset(pid)["params"]["cellDia"] == 21.4
assert storage.delete_preset(pid)
assert storage.list_presets() == []
def test_delete_project_cascades_snapshots():
pid = storage.create_project("p", {"v": 1})
storage.update_project(pid, data={"v": 2}, snapshot=True)
storage.update_project(pid, data={"v": 3}, snapshot=True)
assert len(storage.list_snapshots(pid)) == 2
storage.delete_project(pid)
assert storage.list_snapshots(pid) == []