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
+147
View File
@@ -0,0 +1,147 @@
# ============================================================================
# AR-Autopilot — .gitignore
# ============================================================================
# ----------------------------------------------------------------------------
# Python
# ----------------------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
# Exception: Flutter display app source is in display/lib/ — track it
!display/lib/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.venv/
venv/
env/
ENV/
.env
# pytest / coverage / mypy / ruff
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
.ruff_cache/
# Jupyter
.ipynb_checkpoints/
# ----------------------------------------------------------------------------
# Flutter / Dart (display app)
# ----------------------------------------------------------------------------
display/.dart_tool/
display/.flutter-plugins
display/.flutter-plugins-dependencies
display/.packages
display/.pub-cache/
display/.pub/
display/build/
display/**/build/
display/**/.dart_tool/
display/**/.idea/
display/**/*.iml
display/ios/Pods/
display/ios/.symlinks/
display/android/.gradle/
display/android/local.properties
display/android/captures/
display/android/gradlew
display/android/gradlew.bat
display/android/gradle-wrapper.jar
display/windows/flutter/ephemeral/
display/linux/flutter/ephemeral/
display/macos/Flutter/ephemeral/
# ----------------------------------------------------------------------------
# PlatformIO / ESP32 firmware
# ----------------------------------------------------------------------------
firmware/**/.pio/
firmware/**/.pioenvs/
firmware/**/.piolibdeps/
firmware/**/.vscode/
firmware/**/.clang_complete
firmware/**/.gcc-flags.json
# ----------------------------------------------------------------------------
# IDEs / Editors
# ----------------------------------------------------------------------------
.idea/
.vscode/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
# ----------------------------------------------------------------------------
# OS files
# ----------------------------------------------------------------------------
.DS_Store
Thumbs.db
Desktop.ini
*.lnk
# ----------------------------------------------------------------------------
# Build artifacts / installers
# ----------------------------------------------------------------------------
installer/output/
installer/build/
*.msi
*.exe
*.appack
# ----------------------------------------------------------------------------
# Local config / secrets
# ----------------------------------------------------------------------------
*.local.yaml
*.local.json
secrets/
.env.local
.secrets/
# ----------------------------------------------------------------------------
# Examples output
# ----------------------------------------------------------------------------
examples/output/
# ----------------------------------------------------------------------------
# Logs
# ----------------------------------------------------------------------------
*.log
logs/
# ----------------------------------------------------------------------------
# Claude Code local settings + worktrees (personal — not committed)
# ----------------------------------------------------------------------------
.claude/settings.local.json
.claude/worktrees/
@@ -0,0 +1,72 @@
# ── Python ───────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# ── Environments ─────────────────────────────────────────────────────────────
.env
.venv
venv/
env/
ENV/
env.bak/
# ── PyInstaller ──────────────────────────────────────────────────────────────
*.spec
!arelec.spec # keep the canonical spec checked in
# ── IDE / Editor ─────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# ── Frontend (Vue/Vite/Node) ─────────────────────────────────────────────────
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/coverage/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# ── Project files (user data) ────────────────────────────────────────────────
*.area
!sample_projects/*.area
projects/
# ── Logs ─────────────────────────────────────────────────────────────────────
logs/
*.log
# ── Tests ────────────────────────────────────────────────────────────────────
.pytest_cache/
.coverage
htmlcov/
# ── OS / temp ────────────────────────────────────────────────────────────────
*.tmp
*.bak
*~
@@ -0,0 +1,17 @@
AR-ElecArrangement
Copyright © 2026 Alvaro Enrique Romero Donado. Todos los derechos reservados.
All rights reserved.
Este software es propiedad exclusiva de su autor. Ningún derecho de uso,
copia, modificación, distribución, ingeniería inversa o redistribución se
concede sin licencia escrita expresa del titular.
Para licenciamiento contactar: alro65@gmail.com
------------------------------------------------------------------------------
This software is the exclusive property of its author. No right of use,
copy, modification, distribution, reverse engineering, or redistribution
is granted without express written license from the holder.
For licensing inquiries: alro65@gmail.com
+58
View File
@@ -0,0 +1,58 @@
AR-Autopilot
Copyright (c) 2026 Alvaro Romero. All Rights Reserved.
PROPRIETARY AND CONFIDENTIAL — NOT FOR REDISTRIBUTION
================================================================================
This software, including but not limited to its source code, firmware,
configuration files, default tuning parameters, schematics, documentation,
and any accompanying assets (collectively, the "Software"), is the exclusive
property of Alvaro Romero ("the Author") and is protected by international
copyright law and treaty provisions.
PERMITTED USE
--------------------------------------------------------------------------------
No license, express or implied, is granted to any person or entity to:
(a) use, copy, modify, merge, publish, distribute, sublicense, or sell
copies of the Software, in whole or in part;
(b) reverse-engineer, decompile, disassemble, or otherwise attempt to
derive the source code from compiled binaries or firmware images;
(c) extract, reuse, or redistribute the default PID tuning parameters,
gain schedules, actuator profiles, vessel profiles, or any other
proprietary parameter set bundled with the Software, which constitute
trade secrets of the Author;
(d) use the Software, or any derivative thereof, for the development of
competing autopilot, dynamic positioning, or vessel control products;
except under the terms of a separate written commercial license agreement
signed by the Author.
COMMERCIAL LICENSING
--------------------------------------------------------------------------------
Commercial deployment of the Software on board a vessel requires a per-vessel
license bound to the unique hardware identifier (HWID) of the installation,
issued by the Author. Contact the Author for licensing inquiries.
SAFETY-CRITICAL DISCLAIMER
--------------------------------------------------------------------------------
THE SOFTWARE CONTROLS VESSEL STEERING SYSTEMS AND IS SAFETY-CRITICAL.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, INJURY, LOSS OF LIFE, LOSS OF
VESSEL, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
Installation, commissioning, and operation of the Software must comply with
the relevant maritime regulations and standards applicable to the vessel and
its area of operation, including but not limited to ISO 11674, ISO 16329, and
IMO MSC.64(67).
================================================================================
For licensing inquiries: alro65@gmail.com
================================================================================
@@ -0,0 +1,70 @@
# AR-ElecArrangement
Aplicación para el diseño completo de la instalación eléctrica de un buque
desde la silueta hasta el plano de arrangement firmable.
Familia AR ShipDesign. Cubre yates a motor, yates a vela, pesqueros, lanchas
de pasaje y embarcaciones de trabajo de hasta ~50 m.
## Arquitectura
Servidor Windows + clientes web. Un solo `.exe` empaca el backend FastAPI y
el frontend estático. El PC del usuario es el servidor; iPad, Android u otro
PC se conectan por navegador a `http://<server-ip>:5505`.
```
.exe (Windows)
├─ Backend Python (FastAPI, cálculo eléctrico, exports)
├─ Frontend web (HTML + Vue 3 + Konva.js)
└─ Auto-abre browser a http://localhost:5505
Tablets / otros PC → http://<server-ip>:5505 (LAN, sin Internet)
```
Sin telemetría. Offline absoluto. Catálogos editables por el usuario.
## Normativas soportadas
- ABYC E-11 (small craft USA)
- IEC 60092 (mercante / clase)
- NMEA 2000 y NMEA 0183
- IEEE 45 (buques grandes)
- ISO 10133 (DC small craft) e ISO 13297 (AC small craft)
## Stack
| Capa | Tecnología |
|---|---|
| Backend | Python 3.11 + FastAPI + uvicorn |
| Frontend | Vue 3 + Konva.js |
| Cálculo | numpy, scipy, pandas, networkx |
| Exports | ezdxf (DXF), reportlab (PDF), openpyxl (Excel BOM) |
| Persistencia | `.area` (ZIP con JSON + assets) |
| Empaquetado | PyInstaller |
## Desarrollo local
```bash
# Backend
python -m venv venv
venv\Scripts\activate
pip install -r backend/requirements.txt
python -m uvicorn backend.main:app --reload --port 5505
# Frontend (dev)
cd frontend
npm install
npm run dev # http://localhost:5173 con proxy al backend
```
## Empaquetado .exe
```bash
pyinstaller arelec.spec
# dist/AR-ElecArrangement/AR-ElecArrangement.exe
```
## Licencia
Propietaria. Copyright © 2026 Alvaro Enrique Romero Donado. Ver `LICENSE.txt`.
Para licenciamiento contactar: alro65@gmail.com
+100
View File
@@ -0,0 +1,100 @@
# AR-Autopilot
Professional marine autopilot for vessels in the 30-40 m range (motor yachts, motor sailboats, fishing vessels, small ferries, coastal patrol boats).
Part of the **AR Suite** alongside AR-ECDIS, VMS-Sailor, AR-ShipDesign, AR-ElecArrangement, and AR-StabCol. Sold standalone or bundled with AR-ECDIS.
> **NOT** Dynamic Positioning. **NOT** joystick docking. This is a classic heading-and-track autopilot with intelligent drift compensation, controlling rudder actuators (hydraulic or electric).
---
## Status
**Sprint 0 — Foundations (in progress).**
This sprint delivers the repository structure, core data model, seed library, and a passing test suite. No functional firmware, Studio GUI, or display yet — those start in Sprint 1.
See [`docs/AR_Autopilot_brief.md`](docs/AR_Autopilot_brief.md) for the complete project brief, scope, and roadmap.
---
## Components
| Component | Tech | Purpose |
|---|---|---|
| **Studio** (`arautopilot/studio/`) | Python 3.11 + PySide6 | Project configurator (integrator-side, not shipped to customers). Generates per-vessel `.appack` packages |
| **Firmware** (`firmware/ar_autopilot_v1/`) | C++ on ESP32 via PlatformIO | Real-time PID control, NMEA 2000 + Modbus, safety logic. Runs on the AR-NMEA-IO v1.0 board (shared with VMS-Sailor) |
| **Display** (`display/`) | Flutter Desktop (Win + Linux) | Dedicated bridge cockpit-feel touch display with rotary knob input |
| **Core models** (`arautopilot/core/`) | Pydantic v2 | Shared data model (vessel config, PID config, actuator config, alarms, modes, knob state) |
| **Library** (`arautopilot/library/`) | YAML + JSON | Curated seed: actuator profiles, default tunings per vessel type |
---
## Requirements
- Python **3.11** or newer
- Git
- (Later sprints) PlatformIO, Flutter SDK, WiX Toolset
---
## Quick start (Sprint 0)
```powershell
# Create venv and install
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -U pip
pip install -e ".[dev]"
# Run tests
pytest
# Run the Sprint 0 demo (creates, saves, reloads a project config)
python examples/sprint0_demo.py
```
---
## Repository layout
```
AR-Autopilot/
├── arautopilot/ # Python package (core models, library, studio stubs, tests)
├── firmware/ # ESP32 firmware (Sprint 1+; only pinout.h in Sprint 0)
├── display/ # Flutter dedicated display (Sprint 4+)
├── examples/ # Runnable demos
├── docs/ # Brief + per-sprint design docs
├── installer/ # WiX MSI scripts (later)
└── tools/ # Helper scripts (later)
```
See [`docs/architecture.md`](docs/architecture.md) for a one-page architecture overview.
---
## Sprint roadmap
| Sprint | Focus |
|---|---|
| **0** | Foundations: repo structure, core data model, seed library, tests |
| 1 | Firmware base (I/O, Modbus, NMEA 2000 read, STANDBY mode) |
| 2 | PID inner loop (rudder position control) |
| 3 | PID outer loop + Heading Hold (with ROT feed-forward & gain scheduling) |
| 4 | Studio + basic dedicated display |
| 5 | True Course + Track Keeping (smooth XTE correction) |
| 6 | Safety, alarms, NMEA 2000 publish, VMS alarm consumption |
| 7 | Knob + commissioning + offline auto-tuning |
| 8 | EKF + adaptive tuning + telemetry + VPN |
| 9 | Hardening + integrated testing |
| 10+ | Phase 2 (wind modes for sailboats) and beyond |
Full detail in the brief.
---
## License
Proprietary. All rights reserved. See [`LICENSE.txt`](LICENSE.txt).
Commercial deployment requires a per-vessel license bound to the installation HWID. Contact <alro65@gmail.com> for licensing.
+82
View File
@@ -0,0 +1,82 @@
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec para AR-ElecArrangement.
Empaca:
- Backend Python (backend/main.py como entrypoint)
- Frontend static (frontend/dist/ como recurso bundled)
- Data (data/ con catálogos JSON)
Resultado: dist/AR-ElecArrangement/AR-ElecArrangement.exe
Cuando se ejecuta, inicia el server en 0.0.0.0:5505 y abre el browser.
"""
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
# Recursos que el .exe debe llevar dentro de sys._MEIPASS
datas = [
('frontend/dist', 'frontend/dist'),
('data', 'data'),
]
# Catálogos (futuro): garantiza que cualquier JSON de data/ se incluya
# automáticamente cuando agreguemos más.
# Dependencias que PyInstaller a veces no detecta solo (uvicorn loaders)
hiddenimports = [
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
]
a = Analysis(
['backend/main.py'],
pathex=['backend'],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=['tkinter', 'matplotlib', 'PySide6', 'PyQt5', 'PyQt6'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='AR-ElecArrangement',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True, # mantiene ventana CMD visible — útil para ver logs
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None, # TODO Sprint 14: agregar icon.ico
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='AR-ElecArrangement',
)
+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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+41
View File
@@ -0,0 +1,41 @@
@echo off
title AR-ElecArrangement — Instalación de dependencias
cd /d "%~dp0"
echo.
echo =====================================================
echo AR-ElecArrangement — Setup del entorno Python
echo =====================================================
echo.
:: 1. Crear venv si no existe
if not exist "%~dp0venv\Scripts\python.exe" (
echo [1/3] Creando entorno virtual...
python -m venv venv
if errorlevel 1 (
echo ERROR: no se pudo crear venv. Verifica que Python 3.11+ esté instalado.
pause
exit /b 1
)
) else (
echo [1/3] Entorno virtual ya existe, salto.
)
:: 2. Actualizar pip
echo [2/3] Actualizando pip...
"%~dp0venv\Scripts\python.exe" -m pip install --upgrade pip >nul
:: 3. Instalar dependencias
echo [3/3] Instalando dependencias (esto tarda 1-2 minutos la primera vez)...
"%~dp0venv\Scripts\python.exe" -m pip install -r backend\requirements-dev.txt
if errorlevel 1 (
echo ERROR durante pip install.
pause
exit /b 1
)
echo.
echo =====================================================
echo Instalación lista. Arranca con: start.bat
echo =====================================================
pause
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ar-elecarrangement"
version = "0.1.0"
description = "Diseño eléctrico de buques — servidor + clientes web"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
authors = [
{ name = "Alvaro Enrique Romero Donado", email = "alro65@gmail.com" },
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "B", "UP", "PL", "RUF"]
ignore = ["PLR0913"] # too many arguments — common in marine engineering APIs
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
python_files = ["test_*.py"]
asyncio_mode = "auto"
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
+185
View File
@@ -0,0 +1,185 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "arautopilot"
version = "0.1.0"
description = "AR-Autopilot — Professional marine autopilot for 30-40 m vessels (Studio + firmware + display)"
readme = "README.md"
license = { file = "LICENSE.txt" }
requires-python = ">=3.11"
authors = [{ name = "Alvaro Romero", email = "alro65@gmail.com" }]
keywords = ["marine", "autopilot", "pid", "nmea2000", "esp32", "vessel-control"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Manufacturing",
"License :: Other/Proprietary License",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: System :: Hardware",
]
# Runtime dependencies — kept intentionally minimal for Sprint 0.
# GUI (PySide6), Modbus (pymodbus), serial, etc. are added in later sprints.
dependencies = [
"pydantic>=2.6,<3.0",
"pyyaml>=6.0",
"python-dateutil>=2.8",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=4.1",
"ruff>=0.4",
"mypy>=1.10",
"types-PyYAML",
"types-python-dateutil",
]
# Studio GUI -- Sprint 2.5+. Heavy (~80 MB), kept optional so the core can
# be installed in lean environments (CI, headless test bench).
# PySide6 >= 6.6 includes QtSerialPort on all platforms — no extra dep needed.
studio = [
"PySide6>=6.6",
"pyserial>=3.5",
"platformio>=6.1",
]
# Installer tooling — required on the developer's build machine.
installer = [
"requests>=2.31",
]
# License server — deploy to arelectronics.com VPS.
license-server = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"sqlalchemy>=2.0",
"pydantic>=2.7",
"python-dotenv>=1.0",
]
# AR Display Manager — multi-monitor app switcher for the Integrated Bridge System.
# Same PySide6 dep as the Studio; listed separately so it can be installed standalone.
display-manager = [
"PySide6>=6.6",
]
[project.urls]
Homepage = "https://github.com/alro65/AR-Autopilot"
[tool.setuptools.packages.find]
where = ["."]
include = ["arautopilot*"]
exclude = ["arautopilot.tests*"]
[tool.setuptools.package-data]
"arautopilot.library" = [
"actuators/*.json",
"default_tunings/*.yaml",
"vessel_profiles/*.yaml",
"_schemas/*.json",
]
# ----------------------------------------------------------------------------
# pytest
# ----------------------------------------------------------------------------
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["arautopilot/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--showlocals",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks integration tests",
]
# ----------------------------------------------------------------------------
# Coverage
# ----------------------------------------------------------------------------
[tool.coverage.run]
source = ["arautopilot"]
omit = [
"arautopilot/tests/*",
"arautopilot/studio/*", # GUI not in scope for Sprint 0
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
# ----------------------------------------------------------------------------
# Ruff (linting + formatting)
# ----------------------------------------------------------------------------
[tool.ruff]
line-length = 100
target-version = "py311"
extend-exclude = [
"firmware",
"display",
"installer",
".venv",
"build",
"dist",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"N", # pep8-naming
"SIM", # flake8-simplify
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # function calls in default args (common with pydantic Field)
]
[tool.ruff.lint.per-file-ignores]
"arautopilot/tests/*" = ["N802", "N803"] # test names
# ----------------------------------------------------------------------------
# mypy
# ----------------------------------------------------------------------------
[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_ignores = true
warn_redundant_casts = true
plugins = ["pydantic.mypy"]
exclude = [
"build/",
"dist/",
"firmware/",
"display/",
"installer/",
"arautopilot/studio/", # GUI stubs, not in scope for Sprint 0
]
[[tool.mypy.overrides]]
module = "arautopilot.tests.*"
disallow_untyped_defs = false
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
+34
View File
@@ -0,0 +1,34 @@
@echo off
title AR-ElecArrangement Server
cd /d "%~dp0"
:: Matar proceso previo en el puerto 5505 (si quedó colgado de una corrida anterior)
for /f "tokens=5" %%a in ('netstat -ano 2^>nul ^| findstr ":5505 " ^| findstr "LISTENING"') do (
taskkill /F /PID %%a >nul 2>&1
)
timeout /t 1 /nobreak >nul
echo.
echo =====================================================
echo AR-ElecArrangement
echo Servidor: http://localhost:5505
echo LAN: http://%COMPUTERNAME%:5505
echo =====================================================
echo.
:: Usar venv si existe, si no buscar python en el PATH
if exist "%~dp0venv\Scripts\python.exe" (
set "PY=%~dp0venv\Scripts\python.exe"
) else (
set "PY=python"
)
:: PYTHONPATH para que ``backend.main`` resuelva ``arelec`` del subpaquete
set "PYTHONPATH=%~dp0backend"
:loop
"%PY%" -m backend.main
echo.
echo Servidor detenido. Reiniciando en 3 segundos... (Ctrl+C para salir)
timeout /t 3 /nobreak >nul
goto loop