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