feat: AR-ElecArrangement initial commit — Python FastAPI + uvicorn (LAN desktop app, packaged as .exe via PyInstaller)

This commit is contained in:
2026-07-03 12:18:12 -04:00
commit 5f552ca8ab
22 changed files with 1444 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
"""
AR-ElecArrangement — backend package.
Aplicación servidor para diseño eléctrico de buques. El cliente (browser)
consume la API REST + WebSocket expuesta por ``arelec.main``.
"""
__version__ = "0.1.0"
__author__ = "Alvaro Enrique Romero Donado"
+1
View File
@@ -0,0 +1 @@
"""Modelo de datos del proyecto: ship, decks, zones, appliances, panels, cables…"""
+119
View File
@@ -0,0 +1,119 @@
"""
Raíz del proyecto y serialización al formato ``.area``.
Un ``.area`` es un archivo ZIP que contiene:
::
project.json — metadata del proyecto y modelo serializado
assets/ — recursos (íconos custom, capturas, fotografías)
El formato JSON usa el modelo declarado por el resto de ``arelec.core`` y
está versionado por el campo ``schema_version``. Los .area producidos por
una versión del software pueden ser leídos por versiones posteriores
(migraciones aditivas), nunca por anteriores.
En Sprint 0 el modelo es mínimo (solo metadata) — los sub-modelos (ship,
decks, appliances, panels, cables…) se agregan en sprints siguientes.
"""
from __future__ import annotations
import json
import zipfile
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
@dataclass
class ProjectMetadata:
"""Datos administrativos del proyecto."""
name: str = "Nuevo proyecto"
author: str = ""
company: str = ""
notes: str = ""
schema_version: int = SCHEMA_VERSION
created_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
modified_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
@dataclass
class Project:
"""
Raíz del modelo de datos. Sprint 0 contiene solo metadata; los sub-modelos
(ship, decks, appliances, panels, cable_runs, batteries, etc.) se acoplan
a esta clase en sprints siguientes.
La instancia se serializa íntegra como ``project.json`` dentro del ZIP
``.area``.
"""
metadata: ProjectMetadata = field(default_factory=ProjectMetadata)
# Sub-modelos futuros se irán agregando aquí:
# ship: Ship | None = None
# decks: list[Deck] = field(default_factory=list)
# appliances: list[Appliance] = field(default_factory=list)
# batteries: list[BatteryBank] = field(default_factory=list)
# panels: list[Panel] = field(default_factory=list)
# cable_runs: list[CableRun] = field(default_factory=list)
# ── Serialization ───────────────────────────────────────────────────────
def to_json(self) -> dict[str, Any]:
"""Convertir a dict serializable (JSON-safe)."""
return asdict(self)
@classmethod
def from_json(cls, data: dict[str, Any]) -> "Project":
"""Reconstruir desde dict cargado de ``project.json``."""
meta_dict = data.get("metadata", {})
return cls(metadata=ProjectMetadata(**meta_dict))
# ── .area I/O ───────────────────────────────────────────────────────────
def save(self, path: str | Path) -> None:
"""
Persistir el proyecto a ``path`` (extensión ``.area`` sugerida).
Toca ``metadata.modified_at`` con la hora UTC actual antes de
serializar.
"""
path = Path(path)
self.metadata.modified_at = datetime.now(timezone.utc).isoformat()
payload = json.dumps(self.to_json(), indent=2, ensure_ascii=False)
# ``ZIP_DEFLATED`` para que los .area no crezcan absurdo en proyectos
# grandes — el contenido es texto y comprime ~10×.
with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("project.json", payload)
@classmethod
def load(cls, path: str | Path) -> "Project":
"""
Cargar un proyecto desde ``path``. Lanza:
- ``FileNotFoundError`` si el archivo no existe
- ``zipfile.BadZipFile`` si el archivo no es ZIP válido
- ``ValueError`` si ``schema_version`` es mayor que ``SCHEMA_VERSION``
(el .area fue creado por una versión más nueva del software)
- ``KeyError`` si falta ``project.json`` en el ZIP
"""
path = Path(path)
with zipfile.ZipFile(path, mode="r") as zf:
with zf.open("project.json") as f:
data = json.loads(f.read().decode("utf-8"))
version = data.get("metadata", {}).get("schema_version", 1)
if version > SCHEMA_VERSION:
raise ValueError(
f"El proyecto requiere schema_version={version}, "
f"este software soporta hasta {SCHEMA_VERSION}. "
f"Actualiza AR-ElecArrangement."
)
return cls.from_json(data)
+110
View File
@@ -0,0 +1,110 @@
"""
Conversiones SI ↔ imperial usadas en la UI.
REGLA INTERNA DEL PROYECTO: todo el modelo de datos y todos los cálculos
trabajan en SI (m, kg, A, V, W). Las conversiones a unidades imperiales
(pies, AWG, BTU/h) ocurren ÚNICAMENTE en el borde UI cuando el usuario lo
pide. Persistir AWG o pies en .area está prohibido.
Tablas
------
- AWG ↔ mm²: ABYC E-11 Tabla VI / IEC 60228 (relación logarítmica + valores
tabulados estándar).
- ft ↔ m: 1 ft = 0.3048 m exacto (NIST).
- lb ↔ kg: 1 lb = 0.45359237 kg exacto (NIST).
"""
from __future__ import annotations
# ── Lengths ─────────────────────────────────────────────────────────────────
M_PER_FT: float = 0.3048
FT_PER_M: float = 1.0 / M_PER_FT
def m_to_ft(m: float) -> float:
return m * FT_PER_M
def ft_to_m(ft: float) -> float:
return ft * M_PER_FT
# ── Mass ────────────────────────────────────────────────────────────────────
KG_PER_LB: float = 0.45359237
LB_PER_KG: float = 1.0 / KG_PER_LB
def kg_to_lb(kg: float) -> float:
return kg * LB_PER_KG
def lb_to_kg(lb: float) -> float:
return lb * KG_PER_LB
# ── Wire calibre — AWG ↔ mm² ────────────────────────────────────────────────
# Tabla estándar de conductores (no se usa la fórmula logarítmica analítica
# porque los valores comerciales están redondeados).
# Cobertura: AWG 24 a 4/0 y mm² 0.2 a 120 — rango típico marino.
AWG_TO_MM2: dict[str, float] = {
"24": 0.205,
"22": 0.324,
"20": 0.519,
"18": 0.823,
"16": 1.31,
"14": 2.08,
"12": 3.31,
"10": 5.26,
"8": 8.37,
"6": 13.3,
"4": 21.2,
"2": 33.6,
"1": 42.4,
"1/0": 53.5,
"2/0": 67.4,
"3/0": 85.0,
"4/0": 107.0,
}
# Lista de mm² comerciales IEC para la conversión inversa.
COMMERCIAL_MM2: tuple[float, ...] = (
0.5, 0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0,
50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0,
)
def awg_to_mm2(awg: str) -> float:
"""Convertir designación AWG (p.ej. ``"10"``, ``"1/0"``) a mm²."""
if awg not in AWG_TO_MM2:
raise ValueError(f"AWG no soportado: {awg!r}. Válidos: {list(AWG_TO_MM2)}")
return AWG_TO_MM2[awg]
def mm2_to_awg(mm2: float) -> str:
"""Mapear área (mm²) al AWG cuya área es ≥ la pedida (criterio conservador)."""
if mm2 <= 0:
raise ValueError(f"mm² debe ser > 0, recibido: {mm2}")
# AWG_TO_MM2 está ordenado de menor a mayor área
for awg, area in AWG_TO_MM2.items():
if area >= mm2:
return awg
raise ValueError(f"mm² {mm2} excede el AWG máximo soportado (4/0 = 107 mm²)")
# ── Power / energy ──────────────────────────────────────────────────────────
def hp_to_kw(hp: float) -> float:
"""Caballos métricos (CV / PS) a kW: 1 hp métrico = 0.7355 kW."""
return hp * 0.7355
def kw_to_hp(kw: float) -> float:
return kw / 0.7355
def btu_per_h_to_kw(btu_h: float) -> float:
"""BTU/h a kW (capacidad frigorífica de A/C)."""
return btu_h * 0.000293071
def kw_to_btu_per_h(kw: float) -> float:
return kw / 0.000293071
+196
View File
@@ -0,0 +1,196 @@
"""
AR-ElecArrangement — entrypoint del servidor.
Lanza FastAPI/uvicorn en ``0.0.0.0:5505`` (accesible desde la LAN para que
los tablets se conecten) y sirve el frontend compilado de ``frontend/dist/``.
Cuando se ejecuta como ``python -m backend.main`` o desde el ``.exe`` empacado
por PyInstaller, después de iniciar el server espera 1 s y abre el browser
predeterminado apuntando a ``http://localhost:5505``.
"""
from __future__ import annotations
import os
import re
import sys
import tempfile
import threading
import time
import webbrowser
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from arelec import __version__
from arelec.core.project import Project
# ── Paths ────────────────────────────────────────────────────────────────────
# En desarrollo: __file__ = backend/main.py → BACKEND_DIR = backend/
# Empacado: __file__ está dentro de _MEIPASS → BACKEND_DIR = sys._MEIPASS/backend
if getattr(sys, "frozen", False):
BASE_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
else:
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIST = BASE_DIR / "frontend" / "dist"
DATA_DIR = BASE_DIR / "data"
HOST = "0.0.0.0"
PORT = 5505
# ── FastAPI app ──────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Hook de arranque/apagado del server."""
print(f"[arelec] Servidor iniciado en http://localhost:{PORT} (v{__version__})")
print(f"[arelec] Tablets en LAN: http://<ip-de-este-pc>:{PORT}")
yield
print("[arelec] Servidor detenido.")
app = FastAPI(
title="AR-ElecArrangement",
version=__version__,
description="Diseño eléctrico de buques — backend",
lifespan=lifespan,
)
# Acceso LAN sin restricciones (la app está pensada para LAN privada del usuario,
# no para Internet pública). Si en el futuro se ofrece cloud, restringir aquí.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1024)
# ── API ──────────────────────────────────────────────────────────────────────
@app.get("/api/health")
def health() -> dict[str, str]:
"""Heartbeat para clientes que verifican que el server está vivo."""
return {"status": "ok", "version": __version__}
# ── Project I/O (Sprint 0 — sólo metadata, expandirá en Sprint 1+) ──────────
@app.post("/api/project/new")
def project_new() -> dict[str, Any]:
"""Crear un proyecto vacío y devolver su JSON serializado."""
return Project().to_json()
@app.post("/api/project/open")
async def project_open(file: UploadFile = File(...)) -> dict[str, Any]:
"""
Abrir un archivo ``.area`` subido por el cliente. Lo guarda en un tempfile,
lo carga vía ``Project.load``, devuelve el JSON serializado.
"""
if not (file.filename or "").lower().endswith(".area"):
raise HTTPException(400, "El archivo debe tener extensión .area")
data = await file.read()
if not data:
raise HTTPException(400, "Archivo vacío")
with tempfile.NamedTemporaryFile(suffix=".area", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
proj = Project.load(tmp_path)
return proj.to_json()
except Exception as e: # noqa: BLE001
# No exponemos str(e) al cliente para evitar filtrar rutas internas
# o detalles de implementación. El tipo de error es suficiente.
raise HTTPException(400, "No es un .area válido o el archivo está dañado.") from e
finally:
tmp_path.unlink(missing_ok=True)
_WINDOWS_RESERVED_RE = re.compile(
r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", re.IGNORECASE
)
def _safe_filename(name: str) -> str:
"""Sanitiza el nombre del proyecto para uso seguro como nombre de archivo.
- Elimina caracteres no permitidos en Windows/POSIX (``/``, ``\\``, ``:``,
``*``, ``?``, ``"``, ``<``, ``>``, ``|``, bytes nulos).
- Colapsa espacios/guiones bajos múltiples consecutivos.
- Trunca a 200 caracteres para evitar rutas demasiado largas.
- Sustituye cadena vacía o nombres reservados de Windows por ``proyecto``.
"""
safe = re.sub(r'[\\/:*?"<>|\x00]', "_", name)
safe = re.sub(r"[_\s]{2,}", "_", safe).strip("_").strip()
safe = safe[:200]
if not safe or _WINDOWS_RESERVED_RE.match(safe):
safe = "proyecto"
return safe
@app.post("/api/project/save")
async def project_save(payload: dict[str, Any]) -> FileResponse:
"""
Recibe un proyecto en JSON, lo serializa a ``.area`` y lo devuelve para
descarga directa por el browser.
"""
proj = Project.from_json(payload)
out_dir = Path(tempfile.mkdtemp(prefix="arelec_save_"))
out_path = out_dir / (_safe_filename(proj.metadata.name) + ".area")
proj.save(out_path)
return FileResponse(
path=out_path,
media_type="application/zip",
filename=out_path.name,
)
# ── Frontend (montaje al final para que /api/* tenga precedencia) ────────────
if FRONTEND_DIST.exists():
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
else:
@app.get("/")
def _no_frontend_msg() -> dict[str, str]:
return {
"warning": "frontend/dist no encontrado",
"hint": "Compilar frontend: cd frontend && npm install && npm run build",
}
# ── Auto-launch del browser cuando corre como .exe ───────────────────────────
def _open_browser_when_ready() -> None:
"""Espera 1 s y abre el browser default. Llamado solo desde .exe / __main__."""
time.sleep(1.0)
url = f"http://localhost:{PORT}"
try:
webbrowser.open(url)
except Exception as e: # noqa: BLE001
print(f"[arelec] No se pudo abrir el browser automáticamente: {e}")
print(f"[arelec] Abre manualmente: {url}")
def main() -> None:
"""Entrypoint cuando se corre como ``python -m backend.main`` o ``.exe``."""
# Solo abrir browser cuando no estamos en modo --reload (dev)
if os.environ.get("ARELEC_NO_BROWSER") != "1":
threading.Thread(target=_open_browser_when_ready, daemon=True).start()
uvicorn.run(
"backend.main:app" if not getattr(sys, "frozen", False) else app,
host=HOST,
port=PORT,
log_level="info",
reload=False,
)
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
-r requirements.txt
# ── Tests ────────────────────────────────────────────────────────────────────
pytest>=8.3
pytest-asyncio>=0.25
httpx>=0.28
# ── Linting / formato ────────────────────────────────────────────────────────
ruff>=0.8
mypy>=1.13
+20
View File
@@ -0,0 +1,20 @@
# ── Server ───────────────────────────────────────────────────────────────────
# Versiones mínimas conocidas-buenas; sin pin estricto para que pip resuelva
# la última disponible compatible con la versión de Python instalada.
# Probado contra Python 3.113.14.
fastapi>=0.115
uvicorn[standard]>=0.34
pydantic>=2.11 # Python 3.14 wheels disponibles desde 2.11
websockets>=13.1
python-multipart>=0.0.20 # uploads en /api/project/open
# ── Cálculo (Sprint 4+) ──────────────────────────────────────────────────────
# numpy y pandas se agregan cuando arranquen los motores de cálculo.
# scipy se agrega cuando salga rueda para Python 3.14 (ETA: ya en pre-release).
# networkx se agrega en Sprint 9 (routing automático).
# ── Exports (Sprint 10) ──────────────────────────────────────────────────────
# ezdxf, reportlab, openpyxl entran cuando empecemos exports DXF/PDF/Excel.
# ── Empaquetado .exe (Sprint 14) ─────────────────────────────────────────────
# pyinstaller se agrega al cierre cuando empacamos el .exe final.
View File
@@ -0,0 +1,75 @@
"""
Sprint 0 — sanity: un Project recién creado se guarda y se carga sin pérdida.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from arelec.core.project import Project, ProjectMetadata, SCHEMA_VERSION
def test_project_default_metadata() -> None:
p = Project()
assert p.metadata.name == "Nuevo proyecto"
assert p.metadata.schema_version == SCHEMA_VERSION
assert p.metadata.created_at != ""
def test_save_and_load_roundtrip(tmp_path: Path) -> None:
path = tmp_path / "demo.area"
original = Project(metadata=ProjectMetadata(
name="Yate Demo",
author="ARD",
company="AR ShipDesign",
notes="Sprint 0 test",
))
original.save(path)
assert path.exists()
assert path.stat().st_size > 0
loaded = Project.load(path)
assert loaded.metadata.name == "Yate Demo"
assert loaded.metadata.author == "ARD"
assert loaded.metadata.company == "AR ShipDesign"
assert loaded.metadata.notes == "Sprint 0 test"
assert loaded.metadata.schema_version == SCHEMA_VERSION
def test_save_touches_modified_at(tmp_path: Path) -> None:
p = Project()
original_modified = p.metadata.modified_at
p.save(tmp_path / "t.area")
# Save siempre actualiza modified_at (aunque sea el mismo segundo, queda con
# nueva timestamp ISO — comparamos que fue tocado).
assert p.metadata.modified_at >= original_modified
def test_load_rejects_future_schema(tmp_path: Path) -> None:
"""Un .area creado por una versión más nueva debe rechazarse limpio."""
path = tmp_path / "future.area"
p = Project(metadata=ProjectMetadata(schema_version=SCHEMA_VERSION + 99))
p.save(path)
with pytest.raises(ValueError, match="schema_version"):
Project.load(path)
def test_load_rejects_missing_project_json(tmp_path: Path) -> None:
import zipfile
path = tmp_path / "empty.area"
with zipfile.ZipFile(path, mode="w") as zf:
zf.writestr("readme.txt", "no project.json")
with pytest.raises(KeyError):
Project.load(path)
def test_load_rejects_non_zip(tmp_path: Path) -> None:
import zipfile
path = tmp_path / "garbage.area"
path.write_bytes(b"this is not a zip file")
with pytest.raises(zipfile.BadZipFile):
Project.load(path)
+65
View File
@@ -0,0 +1,65 @@
"""
Sprint 0 — sanity: conversiones SI ↔ imperial round-trip y casos límite.
"""
from __future__ import annotations
import pytest
from arelec.core.units import (
AWG_TO_MM2,
awg_to_mm2,
ft_to_m,
kg_to_lb,
kw_to_hp,
lb_to_kg,
m_to_ft,
mm2_to_awg,
hp_to_kw,
)
def test_length_roundtrip() -> None:
assert m_to_ft(1.0) == pytest.approx(3.28084, rel=1e-4)
assert ft_to_m(m_to_ft(42.0)) == pytest.approx(42.0)
def test_mass_roundtrip() -> None:
assert kg_to_lb(1.0) == pytest.approx(2.20462, rel=1e-4)
assert lb_to_kg(kg_to_lb(100.0)) == pytest.approx(100.0)
def test_power_roundtrip() -> None:
assert hp_to_kw(1.0) == pytest.approx(0.7355)
assert kw_to_hp(hp_to_kw(150.0)) == pytest.approx(150.0)
def test_awg_lookup_known_values() -> None:
# ABYC E-11 tabla VI — valores publicados estándar
assert awg_to_mm2("10") == 5.26
assert awg_to_mm2("4/0") == 107.0
assert awg_to_mm2("14") == 2.08
def test_mm2_to_awg_picks_next_larger() -> None:
# 5.0 mm² no es exactamente AWG 10 (5.26), pero AWG 10 cubre el cable.
# Criterio conservador: elegir AWG cuya área es ≥ pedida.
assert mm2_to_awg(5.0) == "10"
assert mm2_to_awg(5.26) == "10"
assert mm2_to_awg(2.0) == "14" # 2.08
assert mm2_to_awg(100.0) == "4/0" # 107
def test_mm2_to_awg_rejects_invalid() -> None:
with pytest.raises(ValueError):
mm2_to_awg(0)
with pytest.raises(ValueError):
mm2_to_awg(-1.0)
with pytest.raises(ValueError):
mm2_to_awg(500.0) # más grande que 4/0
def test_awg_table_monotonic() -> None:
"""La tabla AWG debe ir de menor a mayor área (asumido por mm2_to_awg)."""
areas = list(AWG_TO_MM2.values())
assert areas == sorted(areas)