Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ backend/ SQL + SDK. Supabase ES el backend; no hay servidor prop
pyproject.toml

frontend/ Next.js 16. Lee Supabase con la clave anon, bajo RLS.
app/ rutas: / (Live), /runs, /runs/[id], /runs/[id]/[seed]
api/clients/ una función por endpoint de PostgREST
api/hooks/ los mismos, envueltos en TanStack Query
app/ rutas: / (Resumen), /live, /runs, /runs/[id], /runs/[id]/[seed]
lib/api/clients/ una función por endpoint de PostgREST
lib/api/hooks/ los mismos, envueltos en TanStack Query
components/ atoms / organisms / screens
lib/ui.ts TODA conversión de unidades, en un solo sitio
styles/colors.css TODOS los colores (tema claro y oscuro), en un solo sitio
Expand Down Expand Up @@ -264,12 +264,18 @@ evolución en vez de borrón y cuenta nueva.
`end()` además de `episode()`. **La simulación tiene que llamarlas** o Live enseñará
el palé ya montado: `episode()` sube todo de golpe y entonces no hay fila `running`
ni `UPDATE` a los que Realtime pueda reaccionar.
- **Front:** `/` (Live) y `/runs` (Ejecuciones), sobre TanStack Query, con Realtime y
- **Front:** `/` (**Resumen** de evolución, la pantalla principal), `/live`, `/runs`
(Ejecuciones) y el detalle de una ejecución, sobre TanStack Query, con Realtime y
los tres estados no felices. El tamaño del palé sale de `config.pallet_size_m` vía
`palletSize()`: **no** se dibuja siempre a 1200x800, porque el palé real puede ser una
maqueta a escala y entonces todas las cotas saldrían mal por el mismo factor.
- **Detalle de ejecución:** `/runs/[id]` y `/runs/[id]/[seed]` ya están, sobre el mismo
`EpisodeDashboard` que Live: cambia de dónde salen los datos, no cómo se ven.
- **Resumen:** una tarjeta por tarea y nivel, con las métricas de `v_run_summary` (éxito,
ciclo, estabilidad, error) y su cambio desde la primera ejecución. Aplica la misma regla que
la comparativa (`comparability()`): la curva usa solo ejecuciones comparables entre sí, que
en un nivel son las del rango de semillas más usado; las demás se dejan fuera y se cuentan.
Medidos, oracle y sembrados **no se mezclan** nunca en una serie.

Aviso de Next 16: `params` es una Promise (`const { id } = await params`) y existen los
tipos globales `PageProps<'/runs/[id]'>`. Hay documentación offline en
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ backend/ SQL + SDK. Supabase IS the backend; there is no server

frontend/ Next.js 16. Reads Supabase with the anon key, under RLS.
app/ routes: / (Live), /runs, /runs/[id], /runs/[id]/[seed]
api/clients/ one function per PostgREST endpoint
api/hooks/ the same, wrapped in TanStack Query
lib/api/clients/ one function per PostgREST endpoint
lib/api/hooks/ the same, wrapped in TanStack Query
components/ atoms / molecules / organisms / screens
lib/ui.ts ALL unit conversion, in one place
lib/pallet.ts drawing geometry; the pallet size is NOT fixed
Expand Down
41 changes: 41 additions & 0 deletions backend/tests/test_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
que significa: negativo = vuelca.
"""

import math
import os
import random
import subprocess
Expand All @@ -18,6 +19,7 @@
from theker_telemetry.pallet import (
TRANSPORT_ACCEL_G,
stability_margin,
support_hull,
support_polygon,
)

Expand All @@ -40,6 +42,45 @@ def test_una_sola_caja_en_una_esquina_apoya_poco():
assert round(y1 - y0, 3) == 0.380


# ── cajas giradas ────────────────────────────────────────────────────────────
# El paletizado gira paquetes a propósito (90° para que entren en la fila). Sin esto,
# `dims_m` se leía como si siempre estuviera alineado con los ejes y una caja de canto
# apoyaba su lado largo: el apoyo salía más grande de lo que es.

def test_girar_90_grados_intercambia_los_lados_del_apoyo():
recta = support_polygon([(0.0, 0.0, 0.48, 0.34)])
canto = support_polygon([(0.0, 0.0, 0.48, 0.34, math.pi / 2)])
assert (round(recta[1] - recta[0], 3), round(recta[3] - recta[2], 3)) == (0.48, 0.34)
assert (round(canto[1] - canto[0], 3), round(canto[3] - canto[2], 3)) == (0.34, 0.48)


def test_una_caja_sin_yaw_se_sigue_escribiendo_con_cuatro_numeros():
"""La tupla de 5 es opcional: el sembrado y la simulación de hoy no cambian."""
assert support_polygon([(0.0, 0.0, 0.48, 0.34)]) == \
support_polygon([(0.0, 0.0, 0.48, 0.34, 0.0)])


def test_el_apoyo_girado_no_regala_las_esquinas():
"""Con una caja a 45° el rectángulo envolvente incluye esquinas donde no apoya nada.

Es la diferencia entre un margen optimista y uno cierto: en este CoG el envolvente
diría +174 mm (estable) y el apoyo real dice que vuelca.
"""
rombo = [(0.0, 0.0, 0.6, 0.6, math.pi / 4)]
x0, x1, y0, y1 = support_polygon(rombo)
por_el_envolvente = min(0.25 - x0, x1 - 0.25, 0.25 - y0, y1 - 0.25)
assert por_el_envolvente > 0
assert stability_margin(0.25, 0.25, 0.0, rombo) < 0


def test_el_casco_de_una_caja_girada_tiene_sus_cuatro_esquinas():
hull = support_hull([(0.0, 0.0, 0.6, 0.6, math.pi / 4)])
assert len(hull) == 4
# A 45° las esquinas caen sobre los ejes, a media diagonal del cuadrado.
d = 0.6 * math.sqrt(2) / 2
assert sorted(round(math.dist((0.0, 0.0), p), 4) for p in hull) == [round(d, 4)] * 4


# ── el margen de estabilidad ─────────────────────────────────────────────────

def test_sin_base_no_hay_margen():
Expand Down
4 changes: 3 additions & 1 deletion backend/theker_telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
from .core import RunLog, Supabase, episode_row, load_env
from .pallet import (
TRANSPORT_ACCEL_G,
corners,
stability_margin,
support_hull,
support_polygon,
)
from .schema import FAILURES, TASKS, EpisodeResult, RunWriter, git_sha

__all__ = [
"EpisodeResult", "RunWriter", "RunLog", "Supabase",
"FAILURES", "TASKS", "git_sha", "episode_row", "load_env",
"TRANSPORT_ACCEL_G", "stability_margin", "support_polygon",
"TRANSPORT_ACCEL_G", "corners", "stability_margin", "support_hull", "support_polygon",
]
79 changes: 67 additions & 12 deletions backend/theker_telemetry/pallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from __future__ import annotations

import math

# Aceleración lateral que se exige aguantar, en g. El caso no es un palé quieto: un
# montón vuelca frenando o girando. Con aceleración lateral `a`, cae si
#
Expand All @@ -20,21 +22,66 @@
# compromiso que el planificador tiene que resolver.
TRANSPORT_ACCEL_G = 0.28

# Caja = (x, y, ancho_x, ancho_y), en metros y en el frame del palé.
Caja = tuple[float, float, float, float]
# Caja = (x, y, ancho_x, ancho_y) o (x, y, ancho_x, ancho_y, yaw), en metros y radianes,
# en el frame del palé. El `yaw` es opcional y vale 0 si no viene: una caja sin girar se
# sigue escribiendo con cuatro números.
Caja = tuple[float, ...]

Punto = tuple[float, float]


def corners(caja: Caja) -> list[Punto]:
"""Las cuatro esquinas de la caja en planta, ya giradas por su `yaw`.

El paletizado gira paquetes a propósito —90° para que entren en la fila—, así que
tratar `(dx, dy)` como si siempre estuviera alineado con los ejes se come el giro:
una caja de 480x340 puesta de canto apoya 340 de ancho, no 480.
"""
x, y, dx, dy = caja[0], caja[1], caja[2], caja[3]
yaw = caja[4] if len(caja) > 4 else 0.0
cos, sin = math.cos(yaw), math.sin(yaw)
return [(x + u * cos - v * sin, y + u * sin + v * cos)
for u, v in ((-dx / 2, -dy / 2), (dx / 2, -dy / 2),
(dx / 2, dy / 2), (-dx / 2, dy / 2))]


def _cross(o: Punto, a: Punto, b: Punto) -> float:
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])


def support_hull(base: list[Caja]) -> list[Punto]:
"""Polígono de apoyo de verdad: la envolvente CONVEXA de las esquinas de la capa 1.

Convexa y no rectangular porque con cajas giradas el rectángulo envolvente incluye
esquinas donde no apoya nada, y eso da un margen optimista: la pantalla diría que
todo va bien hasta el derrumbe, que es justo lo que este indicador existe para
evitar. Sale en orden antihorario, que es lo que asume `stability_margin`.
"""
pts = sorted({p for caja in base for p in corners(caja)})
if len(pts) < 3:
return pts

def media(ordenados: list[Punto]) -> list[Punto]:
out: list[Punto] = []
for p in ordenados:
while len(out) >= 2 and _cross(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out

return media(pts)[:-1] + media(pts[::-1])[:-1]


def support_polygon(base: list[Caja]) -> tuple[float, float, float, float]:
"""Rectángulo que apoya en el palé: la envolvente de las cajas de la capa 1.
"""Rectángulo que encierra el apoyo: `(x0, x1, y0, y1)`, con los giros ya aplicados.

Lo que sostiene el montón es lo que toca el suelo, no el palé entero. Un palé con
una sola caja en una esquina tiene un apoyo pequeño aunque la tabla sea enorme.
Es la caja envolvente del apoyo, útil para encuadrar y para medir vuelo (overhang).
Para el margen de estabilidad se usa `support_hull`, que no regala las esquinas.
"""
xs0 = [x - dx / 2 for x, _, dx, _ in base]
xs1 = [x + dx / 2 for x, _, dx, _ in base]
ys0 = [y - dy / 2 for _, y, _, dy in base]
ys1 = [y + dy / 2 for _, y, _, dy in base]
return min(xs0), max(xs1), min(ys0), max(ys1)
pts = [p for caja in base for p in corners(caja)]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return min(xs), max(xs), min(ys), max(ys)


def stability_margin(cog_x: float, cog_y: float, cog_z: float,
Expand All @@ -47,6 +94,14 @@ def stability_margin(cog_x: float, cog_y: float, cog_z: float,
"""
if not base:
return 0.0
x0, x1, y0, y1 = support_polygon(base)
d_edge = min(cog_x - x0, x1 - cog_x, cog_y - y0, y1 - cog_y)
hull = support_hull(base)
if len(hull) < 3: # apoyo degenerado (sin área): no sostiene nada
return 0.0

# Distancia con signo del CoG a cada lado; en un polígono antihorario es positiva
# por dentro. La menor es la que manda: el montón cae por su lado más flojo.
d_edge = min(
_cross(a, b, (cog_x, cog_y)) / math.dist(a, b)
for a, b in zip(hull, hull[1:] + hull[:1])
)
return d_edge - accel_g * cog_z
8 changes: 6 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ PostgREST, más los canales Realtime. Este documento sale de `backend/sql/001_sc
`backend/sql/002_design.sql` y `backend/theker_telemetry/core.py`; las respuestas de ejemplo son
la forma de las columnas, no capturas de una base real.

Los clientes del front que los consumen están en `frontend/api/clients/` y los hooks en
`frontend/api/hooks/`. Los tipos siguen en `frontend/lib/supabase.ts`, que es lo que
Los clientes del front que los consumen están en `frontend/lib/api/clients/` y los hooks en
`frontend/lib/api/hooks/`. Los tipos siguen en `frontend/lib/supabase.ts`, que es lo que
compara `backend/tests/test_contrato.py` contra las columnas de verdad.

## 1. Convenciones
Expand Down Expand Up @@ -168,6 +168,10 @@ Solo aparecen causas con al menos un episodio: un run sin fallos devuelve `[]`.
`planned_pose`/`actual_pose` son jsonb y pueden ser `null`. `dims_m` es `[largo, ancho, alto]`.
Las tres poses/dimensiones están en metros y radianes.

El `yaw` de la pose **se dibuja**: `dims_m` va en el eje del paquete, no en el del palé, así que
un paquete girado 90° ocupa `ancho` en X y `largo` en Y. Quien produce episodios no tiene que
premasticarlo —no hay que intercambiar `dims_m`—: basta con mandar el `yaw` de verdad.

### 2.5 `GET /pallet_states`: traza del centro de gravedad

`?select=*&episode_id=eq.{uuid}&order=after_seq.asc` → `getPalletStates(id)` / `usePalletStates(id)`
Expand Down
5 changes: 5 additions & 0 deletions frontend/app/live/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LiveScreen } from "@/components/screens/LiveScreen";

export default function Page() {
return <LiveScreen />;
}
4 changes: 2 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LiveScreen } from "@/components/screens/LiveScreen";
import { DashboardScreen } from "@/components/screens/DashboardScreen";

export default function Page() {
return <LiveScreen />;
return <DashboardScreen />;
}
1 change: 1 addition & 0 deletions frontend/components/atoms/Badge/Badge.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
}
.neutral { color: var(--text-3); }
.ok { color: var(--ok); background: var(--ok-fill); }
.warn { color: var(--warn); background: var(--warn-fill); }
.bad { color: var(--bad); background: var(--bad-fill-soft); }
.oracle { color: var(--oracle); }
.synthetic { color: var(--synthetic); }
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/atoms/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import styles from "./Badge.module.css";
/** `oracle` y `synthetic` van con trama diagonal a propósito: son números que no son de
* verdad y tienen que molestar a la vista. */
export function Badge({ tone = "neutral", children }: {
tone?: "neutral" | "oracle" | "synthetic" | "ok" | "bad"; children: ReactNode;
tone?: "neutral" | "oracle" | "synthetic" | "ok" | "warn" | "bad"; children: ReactNode;
}) {
return <span className={cx(styles.badge, styles[tone])}>{children}</span>;
}
5 changes: 4 additions & 1 deletion frontend/components/atoms/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";

export type IconName = "check" | "arrowDown" | "crosshair" | "circleDot" | "wave" | "x" | "flag" | "chevronsUpDown" | "sun" | "moon" | "chevronLeft" | "chevronRight" | "arrowLeft";
export type IconName = "check" | "arrowDown" | "crosshair" | "circleDot" | "wave" | "x" | "flag" | "chevronsUpDown" | "sun" | "moon" | "chevronLeft" | "chevronRight" | "arrowLeft" | "arrowUp" | "minus" | "info";

/* Trazos de 16×16. Iconos de línea, sin relleno: ninguno lleva color propio, heredan
`currentColor` del que los use. */
Expand All @@ -18,6 +18,9 @@ const PATHS: Record<IconName, ReactNode> = {
chevronLeft: <path d="M10 3.5 5.5 8l4.5 4.5" />,
chevronRight: <path d="M6 3.5 10.5 8 6 12.5" />,
arrowLeft: <path d="M13 8H3M7 4 3 8l4 4" />,
arrowUp: <path d="M8 13.5v-10M4 7.5l4-4 4 4" />,
minus: <path d="M3.5 8h9" />,
info: (<><circle cx="8" cy="8" r="6" /><path d="M8 7.2v3.6" /><circle cx="8" cy="4.9" r="0.55" fill="currentColor" /></>),
};

/** Decorativo: el significado lo lleva el texto de al lado, no el icono. */
Expand Down
10 changes: 8 additions & 2 deletions frontend/components/atoms/SceneRect/SceneRect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ type Props = {
variant: SceneRectVariant;
/** Solo lo usa `support`: toma el color del estado de estabilidad. */
tone?: "ok" | "warn" | "bad";
/** Giro del paquete, ya en grados de pantalla y alrededor de su centro. Lo calcula
* quien sabe de metros; aquí solo se aplica. */
rotate?: { deg: number; cx: number; cy: number };
};

/** Un rectángulo del dibujo del palé. Coordenadas ya en píxeles: no sabe de metros. */
export function SceneRect({ variant, tone = "ok", ...box }: Props) {
export function SceneRect({ variant, tone = "ok", rotate, ...box }: Props) {
const rx = variant === "frame" || variant === "deck" ? 8 : 3;
return <rect className={cx(styles.rect, styles[variant], styles[tone])} rx={rx} {...box} />;
return (
<rect className={cx(styles.rect, styles[variant], styles[tone])} rx={rx} {...box}
transform={rotate ? `rotate(${rotate.deg} ${rotate.cx} ${rotate.cy})` : undefined} />
);
}
54 changes: 54 additions & 0 deletions frontend/components/atoms/Trend/Trend.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export type TrendPoint = { value: number | null; title: string };

export type TrendGeometry = {
width: number;
height: number;
/** Un tramo por cada racha de valores; un hueco (null) corta la línea. */
path: string;
dots: Array<{ x: number; y: number; title: string; last: boolean }>;
/** Altura del primer valor: la referencia contra la que se lee el avance. */
baselineY: number | null;
};

const W = 400;
const H = 64;
const PAD_X = 10;
const PAD_Y = 10;
/** Con más puntos que esto los círculos se pisan: solo se marca el último. */
const MAX_DOTS = 40;

/** De valores a coordenadas. La escala va del mínimo al máximo de LA SERIE: enseña la forma
* de la evolución, no el valor absoluto (el valor ya lo dice el número de al lado). Una
* serie plana se dibuja en el centro y no como una subida exagerada de nada. */
export function trendGeometry(points: TrendPoint[]): TrendGeometry {
const values = points.map((p) => p.value).filter((v): v is number => v != null);
const lo = Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo;

const x = (i: number) =>
points.length === 1 ? W / 2 : PAD_X + (i / (points.length - 1)) * (W - PAD_X * 2);
const y = (v: number) =>
span === 0 ? H / 2 : PAD_Y + ((hi - v) / span) * (H - PAD_Y * 2);

let path = "";
let pen = false;
const dots: TrendGeometry["dots"] = [];
let lastIndex = -1;
points.forEach((p, i) => { if (p.value != null) lastIndex = i; });

points.forEach((p, i) => {
if (p.value == null) { pen = false; return; }
path += `${pen ? "L" : "M"}${x(i).toFixed(1)},${y(p.value).toFixed(1)} `;
pen = true;
if (points.length <= MAX_DOTS || i === lastIndex) {
dots.push({ x: x(i), y: y(p.value), title: p.title, last: i === lastIndex });
}
});

const first = values[0];
return {
width: W, height: H, path: path.trim(), dots,
baselineY: first == null || values.length < 2 ? null : y(first),
};
}
19 changes: 19 additions & 0 deletions frontend/components/atoms/Trend/Trend.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.trend { width: 100%; height: 64px; display: block; overflow: visible; }

.ok { --tone: var(--ok); }
.bad { --tone: var(--bad); }
.muted { --tone: var(--text-3); }

.line {
fill: none;
stroke: var(--tone);
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.dashed { stroke-dasharray: 5 5; }

.baseline { stroke: var(--border-hard); stroke-width: 1; stroke-dasharray: 2 4; }

.dot { fill: var(--surface); stroke: var(--tone); stroke-width: 1.75; }
.last { fill: var(--tone); stroke: var(--surface); stroke-width: 2; }
Loading
Loading