feat: perfiles de jitter intercambiables desde Telegram
Cinco perfiles con distinto punto en el compromiso entre variedad visible y
minutos de presencia de mas. Todos dan saldo 0. Se cambian en caliente con
/perfil, con botones, y al cambiar se vuelven a tirar los dados de todos los
dias del horizonte que no se hayan fichado ya.
min/dia madrugon entrada 09:00
discreto 2,8 1,3 2 minutos, moda 75%
natural 4,1 1,8 5 minutos, moda 51% <- por defecto
camuflado 5,5 2,1 4 minutos, moda 43%
disperso 7,9 3,0 6 minutos, moda 29%
sin_madrugar 5,7 1,3 2 minutos, moda 77%
(historico real del usuario: 4 minutos, moda 38%)
La distincion util no es solo el coste total sino DONDE cae. Adelantar la
entrada de la mañana alarga el dia; adelantar la vuelta de comer solo acorta la
comida y se sale a la misma hora. De ahi "sin_madrugar": entrada de la mañana lo
mas pegada posible a las 9 (madrugon 1,26 min/dia, el mas bajo de todos, por
debajo incluso de "discreto") y toda la variedad en la pausa y las salidas.
Para eso los perfiles admiten afinar por tramo con sufijo _w<n>:
entry_median_w0 gana a entry_median para el tramo de la mañana.
tools/perfiles.py mide cada perfil sobre los 226 dias laborables del calendario
real; las cifras de arriba salen de ahi y van guardadas como metadatos para que
/perfil las muestre sin recalcular.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
README.md
45
README.md
@@ -56,6 +56,7 @@ Se registran solos en el menú del chat al arrancar.
|
|||||||
| `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad |
|
| `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad |
|
||||||
| `/saldo` | saldo del mes y acumulado |
|
| `/saldo` | saldo del mes y acumulado |
|
||||||
| `/plan` | plan de hoy, con botón para re-tirar los dados |
|
| `/plan` | plan de hoy, con botón para re-tirar los dados |
|
||||||
|
| `/perfil [nombre]` | ver y cambiar el perfil de jitter |
|
||||||
| `/proximos [n]` | previsión de los próximos días laborables y sus rangos |
|
| `/proximos [n]` | previsión de los próximos días laborables y sus rangos |
|
||||||
| `/fichar` `/desfichar` | fichaje manual inmediato |
|
| `/fichar` `/desfichar` | fichaje manual inmediato |
|
||||||
| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días |
|
| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días |
|
||||||
@@ -72,23 +73,38 @@ Doce minutos antes de cada fichaje llega un aviso con killswitch:
|
|||||||
|
|
||||||
El bot sólo atiende al `chat_id` de `config.json`; cualquier otro chat se ignora.
|
El bot sólo atiende al `chat_id` de `config.json`; cualquier otro chat se ignora.
|
||||||
|
|
||||||
|
Perfiles de jitter
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Timenet sólo cuenta dentro de la ventana teórica, así que las horas de fichaje
|
||||||
|
sólo pueden variar hacia el lado seguro, y alejarse cuesta presencia real. Más
|
||||||
|
variedad visible = más minutos ahí. Cada perfil elige un punto de ese compromiso.
|
||||||
|
Los cinco dan **saldo 0**; se cambian en caliente con `/perfil`.
|
||||||
|
|
||||||
|
| perfil | min/día | madrugón | entrada 09:00 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| discreto | 2,8 | 1,3 | 2 minutos, moda 75 % |
|
||||||
|
| **natural** (por defecto) | 4,1 | 1,8 | 5 minutos, moda 51 % |
|
||||||
|
| camuflado | 5,5 | 2,1 | 4 minutos, moda 43 % |
|
||||||
|
| disperso | 7,9 | 3,0 | 6 minutos, moda 29 % |
|
||||||
|
| sin madrugar | 5,7 | **1,3** | 2 minutos, moda 77 % |
|
||||||
|
| *histórico real* | — | — | *4 minutos, moda 38 %* |
|
||||||
|
|
||||||
|
`min/día` es la presencia total de más. `madrugón` es la parte de esos minutos
|
||||||
|
que te levantas antes por la entrada de la mañana — el resto se lo come la pausa
|
||||||
|
de comer y la salida, que no alargan el día. Por eso existe *sin madrugar*:
|
||||||
|
entrada de la mañana pegada a las 9 y toda la variedad en la comida y la salida.
|
||||||
|
|
||||||
|
Medido sobre los 226 días laborables del año con `tools/perfiles.py`. Un perfil
|
||||||
|
puede afinar por tramo con sufijo `_w<n>`: `entry_median_w0` gana a `entry_median`
|
||||||
|
para el tramo de la mañana.
|
||||||
|
|
||||||
Configuración
|
Configuración
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
Lo esencial va en `config.json` (ver `sample.config.json`). Los valores por defecto
|
Lo esencial va en `config.json` (ver `sample.config.json`). `geo_jitter_meters`
|
||||||
del jitter están en `app/settings.py`:
|
desplaza la posición dentro de un disco de ese radio; sin él, las coordenadas
|
||||||
|
salen idénticas al sexto decimal en todos los fichajes.
|
||||||
| perfil | `entry_before` / `exit_after` (s) | presencia extra | colchón mínimo |
|
|
||||||
|---|---|---|---|
|
|
||||||
| holgado | 40-150 / 30-130 | 5,67 min/día | 30 s |
|
|
||||||
| **equilibrado** (por defecto) | 30-100 / 20-70 | 3,55 min/día | 20 s |
|
|
||||||
| ajustado | 20-70 / 15-45 | 2,70 min/día | 15 s |
|
|
||||||
|
|
||||||
Los tres dan saldo 0; cambia cuánta presencia real cuestan y cuánto retraso del
|
|
||||||
daemon aguantan antes de perder un minuto.
|
|
||||||
|
|
||||||
`geo_jitter_meters` desplaza la posición dentro de un disco de ese radio. Sin él,
|
|
||||||
las coordenadas salen idénticas al sexto decimal en todos los fichajes.
|
|
||||||
|
|
||||||
Herramientas
|
Herramientas
|
||||||
------------
|
------------
|
||||||
@@ -97,6 +113,7 @@ Herramientas
|
|||||||
./venv/bin/python3 tools/tn_inspect.py # calendario y saldo, contra la API real
|
./venv/bin/python3 tools/tn_inspect.py # calendario y saldo, contra la API real
|
||||||
./venv/bin/python3 tools/dryrun.py # simula el año y verifica que no pierde minutos
|
./venv/bin/python3 tools/dryrun.py # simula el año y verifica que no pierde minutos
|
||||||
./venv/bin/python3 tools/test_daemon.py # 19 pruebas con reloj simulado, sin red
|
./venv/bin/python3 tools/test_daemon.py # 19 pruebas con reloj simulado, sin red
|
||||||
|
./venv/bin/python3 tools/perfiles.py # mide cada perfil sobre el calendario real
|
||||||
```
|
```
|
||||||
|
|
||||||
Modo antiguo (CLI de un disparo)
|
Modo antiguo (CLI de un disparo)
|
||||||
|
|||||||
67
app/bot.py
67
app/bot.py
@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
|||||||
from telegram.constants import ParseMode
|
from telegram.constants import ParseMode
|
||||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||||
|
|
||||||
from app import jitter
|
from app import jitter, profiles
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -101,6 +101,7 @@ MENU: list[tuple[str, str]] = [
|
|||||||
("hoy", "Plan y estado de hoy"),
|
("hoy", "Plan y estado de hoy"),
|
||||||
("saldo", "Saldo del mes y acumulado"),
|
("saldo", "Saldo del mes y acumulado"),
|
||||||
("plan", "Ver el plan y re-tirar los dados"),
|
("plan", "Ver el plan y re-tirar los dados"),
|
||||||
|
("perfil", "Ver y cambiar el perfil de jitter"),
|
||||||
("proximos", "Previsión de los próximos días [nº de días]"),
|
("proximos", "Previsión de los próximos días [nº de días]"),
|
||||||
("fichar", "Fichar ENTRADA ahora"),
|
("fichar", "Fichar ENTRADA ahora"),
|
||||||
("desfichar", "Fichar SALIDA ahora"),
|
("desfichar", "Fichar SALIDA ahora"),
|
||||||
@@ -131,7 +132,7 @@ class BotUI:
|
|||||||
cmds = {
|
cmds = {
|
||||||
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
||||||
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
||||||
"proximos": self.cmd_proximos,
|
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
|
||||||
"off": self.cmd_off, "on": self.cmd_on,
|
"off": self.cmd_off, "on": self.cmd_on,
|
||||||
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
||||||
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
||||||
@@ -276,6 +277,56 @@ class BotUI:
|
|||||||
await update.effective_message.reply_text("\n".join(lines),
|
await update.effective_message.reply_text("\n".join(lines),
|
||||||
parse_mode=ParseMode.HTML)
|
parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
def _perfil_texto(self) -> str:
|
||||||
|
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
||||||
|
lines = ["<b>Perfil de jitter</b>",
|
||||||
|
"Cuánta variedad tienen las horas de fichaje, y lo que cuesta "
|
||||||
|
"en minutos de estar ahí. Todos dan saldo 0.\n"]
|
||||||
|
for n in profiles.nombres():
|
||||||
|
st = profiles.stats(n)
|
||||||
|
marca = "▶️ " if n == actual else "· "
|
||||||
|
lines.append(
|
||||||
|
f"{marca}<b>{profiles.titulo(n)}</b> — {st['coste']:.1f} min/día "
|
||||||
|
f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%")
|
||||||
|
lines.append(f" <i>{profiles.resumen(n)}</i>")
|
||||||
|
lines.append("\n<i>madrugón = de esos minutos, los que te levantas antes "
|
||||||
|
"por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.</i>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _perfil_teclado(self) -> InlineKeyboardMarkup:
|
||||||
|
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
||||||
|
botones = [InlineKeyboardButton(
|
||||||
|
("✅ " if n == actual else "") + profiles.titulo(n),
|
||||||
|
callback_data=f"{CB}:perfil:{n}") for n in profiles.nombres()]
|
||||||
|
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
|
||||||
|
return InlineKeyboardMarkup(filas)
|
||||||
|
|
||||||
|
async def cmd_perfil(self, update: Update, ctx) -> None:
|
||||||
|
if ctx.args:
|
||||||
|
n = ctx.args[0].strip().lower()
|
||||||
|
if not profiles.existe(n):
|
||||||
|
await update.effective_message.reply_text(
|
||||||
|
"No conozco ese perfil. Son: " + ", ".join(profiles.nombres()))
|
||||||
|
return
|
||||||
|
await self.svc.cambiar_perfil(n)
|
||||||
|
await update.effective_message.reply_text(
|
||||||
|
f"✅ Perfil <b>{profiles.titulo(n)}</b>. Replanificados los días futuros.",
|
||||||
|
parse_mode=ParseMode.HTML)
|
||||||
|
return
|
||||||
|
await update.effective_message.reply_text(
|
||||||
|
self._perfil_texto(), parse_mode=ParseMode.HTML,
|
||||||
|
reply_markup=self._perfil_teclado())
|
||||||
|
|
||||||
|
async def _cambiar_perfil(self, q, nombre: str) -> None:
|
||||||
|
if not profiles.existe(nombre):
|
||||||
|
await q.answer("Perfil desconocido")
|
||||||
|
return
|
||||||
|
await q.answer(f"Cambiando a {profiles.titulo(nombre)}…")
|
||||||
|
await self.svc.cambiar_perfil(nombre)
|
||||||
|
await q.edit_message_text(
|
||||||
|
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
||||||
|
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
|
||||||
|
|
||||||
async def cmd_off(self, update: Update, ctx) -> None:
|
async def cmd_off(self, update: Update, ctx) -> None:
|
||||||
days = parse_range(ctx.args, self.svc.today())
|
days = parse_range(ctx.args, self.svc.today())
|
||||||
if not days:
|
if not days:
|
||||||
@@ -348,10 +399,16 @@ class BotUI:
|
|||||||
if not self._mine(update):
|
if not self._mine(update):
|
||||||
await q.answer()
|
await q.answer()
|
||||||
return
|
return
|
||||||
|
partes = q.data.split(":")
|
||||||
|
action = partes[1] if len(partes) > 1 else ""
|
||||||
|
|
||||||
|
if action == "perfil":
|
||||||
|
await self._cambiar_perfil(q, partes[2])
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, action, day_s, idx_s = q.data.split(":")
|
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
||||||
day, idx = date.fromisoformat(day_s), int(idx_s)
|
except (IndexError, ValueError):
|
||||||
except ValueError:
|
|
||||||
await q.answer("Botón no reconocido")
|
await q.answer("Botón no reconocido")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ class Service:
|
|||||||
logger.info("plan de %s: %s", day, [str(p) for p in plan])
|
logger.info("plan de %s: %s", day, [str(p) for p in plan])
|
||||||
return self.store.get_plan(day)
|
return self.store.get_plan(day)
|
||||||
|
|
||||||
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False) -> None:
|
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False,
|
||||||
|
force_all: bool = False) -> None:
|
||||||
"""Planifica hoy y los proximos dias, para que la prevision sea exacta."""
|
"""Planifica hoy y los proximos dias, para que la prevision sea exacta."""
|
||||||
cal = await asyncio.to_thread(self.executor.calendar)
|
cal = await asyncio.to_thread(self.executor.calendar)
|
||||||
hoy = self.today()
|
hoy = self.today()
|
||||||
@@ -143,7 +144,7 @@ class Service:
|
|||||||
for i in range(dias):
|
for i in range(dias):
|
||||||
d = hoy + timedelta(days=i)
|
d = hoy + timedelta(days=i)
|
||||||
antes = bool(self.store.get_plan(d))
|
antes = bool(self.store.get_plan(d))
|
||||||
await self._plan_un_dia(d, cal, force=force and i == 0)
|
await self._plan_un_dia(d, cal, force=force_all or (force and i == 0))
|
||||||
if not antes and self.store.get_plan(d):
|
if not antes and self.store.get_plan(d):
|
||||||
nuevos += 1
|
nuevos += 1
|
||||||
self._planned_for = hoy
|
self._planned_for = hoy
|
||||||
@@ -157,6 +158,13 @@ class Service:
|
|||||||
await self.ensure_horizon(force=force)
|
await self.ensure_horizon(force=force)
|
||||||
return self.store.get_plan(self.today())
|
return self.store.get_plan(self.today())
|
||||||
|
|
||||||
|
async def cambiar_perfil(self, nombre: str) -> None:
|
||||||
|
"""Cambia el perfil y vuelve a tirar los dados de todo lo que no se ha fichado."""
|
||||||
|
self.store.put("perfil", nombre)
|
||||||
|
self.store.log("perfil", f"cambiado a {nombre}")
|
||||||
|
logger.info("perfil de jitter cambiado a %s", nombre)
|
||||||
|
await self.ensure_horizon(force_all=True)
|
||||||
|
|
||||||
async def cancel_pending_today(self, reason: str) -> None:
|
async def cancel_pending_today(self, reason: str) -> None:
|
||||||
day = self.today()
|
day = self.today()
|
||||||
for r in self.store.pending(day):
|
for r in self.store.pending(day):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from app import jitter
|
from app import jitter, profiles
|
||||||
from app.schedule import Calendar
|
from app.schedule import Calendar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,7 +46,19 @@ class Planner:
|
|||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.store = store
|
self.store = store
|
||||||
self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid"))
|
self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid"))
|
||||||
self.j = cfg["jitter"]
|
|
||||||
|
@property
|
||||||
|
def perfil(self) -> str:
|
||||||
|
"""Perfil activo. Se lee del store en cada plan, para poder cambiarlo en caliente."""
|
||||||
|
if self.store:
|
||||||
|
nombre = self.store.get("perfil", self.cfg.get("perfil", profiles.DEFECTO))
|
||||||
|
if profiles.existe(nombre):
|
||||||
|
return nombre
|
||||||
|
return self.cfg.get("perfil", profiles.DEFECTO)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def j(self) -> dict:
|
||||||
|
return profiles.params(self.perfil)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ drift
|
# ------------------------------------------------------------------ drift
|
||||||
def _drift_key(self, window_idx: int, typ: int) -> str:
|
def _drift_key(self, window_idx: int, typ: int) -> str:
|
||||||
@@ -55,7 +67,8 @@ class Planner:
|
|||||||
def _drift(self, window_idx: int, typ: int, rng: random.Random) -> float:
|
def _drift(self, window_idx: int, typ: int, rng: random.Random) -> float:
|
||||||
key = self._drift_key(window_idx, typ)
|
key = self._drift_key(window_idx, typ)
|
||||||
prev = float(self.store.get(key, 0.0)) if self.store else 0.0
|
prev = float(self.store.get(key, 0.0)) if self.store else 0.0
|
||||||
nxt = jitter.next_drift(prev, self.j["drift_sigma"], self.j["drift_clamp"], rng)
|
j = self.j
|
||||||
|
nxt = jitter.next_drift(prev, j["drift_sigma"], j["drift_clamp"], rng)
|
||||||
if self.store:
|
if self.store:
|
||||||
self.store.put(key, nxt)
|
self.store.put(key, nxt)
|
||||||
return nxt
|
return nxt
|
||||||
@@ -69,6 +82,7 @@ class Planner:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
rng = rng or random.Random()
|
rng = rng or random.Random()
|
||||||
|
j = self.j
|
||||||
out: list[PlannedCheck] = []
|
out: list[PlannedCheck] = []
|
||||||
idx = 0
|
idx = 0
|
||||||
|
|
||||||
@@ -78,19 +92,24 @@ class Planner:
|
|||||||
|
|
||||||
# ENTRADA: siempre antes de la hora teorica. El tope se acota ademas
|
# ENTRADA: siempre antes de la hora teorica. El tope se acota ademas
|
||||||
# con el margen de entrada que declara el propio tipo de jornada.
|
# con el margen de entrada que declara el propio tipo de jornada.
|
||||||
tope = min(self.j["entry_cap"], max(w.entry_margin * 60 // 2, 60)) if w.entry_margin \
|
cap = profiles.valor(j, "entry", "cap", wi)
|
||||||
else self.j["entry_cap"]
|
tope = min(cap, max(w.entry_margin * 60 // 2, 60)) if w.entry_margin else cap
|
||||||
off = jitter.offset_seconds(
|
off = jitter.offset_seconds(
|
||||||
self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
|
profiles.valor(j, "entry", "floor", wi),
|
||||||
|
profiles.valor(j, "entry", "median", wi),
|
||||||
|
profiles.valor(j, "entry", "sigma", wi),
|
||||||
tope, self._drift(wi, ENTRADA, rng), rng)
|
tope, self._drift(wi, ENTRADA, rng), rng)
|
||||||
off = max(off, self.j["entry_guard"])
|
off = max(off, j["entry_guard"])
|
||||||
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
|
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# SALIDA: siempre despues
|
# SALIDA: siempre despues
|
||||||
off = jitter.offset_seconds(
|
off = jitter.offset_seconds(
|
||||||
self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
|
profiles.valor(j, "exit", "floor", wi),
|
||||||
self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
|
profiles.valor(j, "exit", "median", wi),
|
||||||
|
profiles.valor(j, "exit", "sigma", wi),
|
||||||
|
profiles.valor(j, "exit", "cap", wi),
|
||||||
|
self._drift(wi, SALIDA, rng), rng)
|
||||||
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
|
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
|
|||||||
122
app/profiles.py
Normal file
122
app/profiles.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Perfiles de jitter intercambiables.
|
||||||
|
|
||||||
|
El compromiso de fondo no tiene truco: Timenet solo cuenta dentro de la ventana
|
||||||
|
teorica, asi que solo se puede variar hacia el lado seguro, y alejarse cuesta
|
||||||
|
presencia real. Mas variedad visible = mas minutos ahi. Cada perfil elige un punto
|
||||||
|
de ese compromiso.
|
||||||
|
|
||||||
|
Un perfil puede afinar por tramo con sufijo `_w<n>`: `entry_median_w0` gana a
|
||||||
|
`entry_median` para el tramo 0. Sirve para repartir el coste donde menos molesta:
|
||||||
|
adelantar la entrada de la mañana ALARGA el dia, mientras que adelantar la vuelta
|
||||||
|
de comer solo ACORTA la comida y se sale a la misma hora.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Valores comunes. Un perfil solo declara lo que cambia.
|
||||||
|
BASE = {
|
||||||
|
"entry_floor": 12,
|
||||||
|
"entry_median": 58,
|
||||||
|
"entry_sigma": 0.95,
|
||||||
|
"entry_cap": 330,
|
||||||
|
"exit_floor": 10,
|
||||||
|
"exit_median": 42,
|
||||||
|
"exit_sigma": 0.95,
|
||||||
|
"exit_cap": 300,
|
||||||
|
"drift_sigma": 0.30,
|
||||||
|
"drift_clamp": 0.75,
|
||||||
|
"entry_guard": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
PERFILES: dict[str, dict] = {
|
||||||
|
"discreto": {
|
||||||
|
"titulo": "Discreto",
|
||||||
|
"resumen": "Lo más barato. Casi siempre justo antes de la hora.",
|
||||||
|
"stats": {'coste': 2.82, 'madrugon': 1.29, 'mins': 2, 'moda': 75},
|
||||||
|
"params": {
|
||||||
|
"entry_median": 42, "entry_sigma": 0.80, "entry_cap": 240,
|
||||||
|
"exit_median": 30, "exit_sigma": 0.80, "exit_cap": 210,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"natural": {
|
||||||
|
"titulo": "Natural",
|
||||||
|
"resumen": "Equilibrio entre variedad y minutos de más.",
|
||||||
|
"stats": {'coste': 4.14, 'madrugon': 1.81, 'mins': 5, 'moda': 51},
|
||||||
|
"params": {
|
||||||
|
"entry_median": 58, "entry_sigma": 0.95, "entry_cap": 330,
|
||||||
|
"exit_median": 42, "exit_sigma": 0.95, "exit_cap": 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"camuflado": {
|
||||||
|
"titulo": "Camuflado",
|
||||||
|
"resumen": "Se parece a tu histórico real. Cuesta algo más.",
|
||||||
|
"stats": {'coste': 5.47, 'madrugon': 2.08, 'mins': 4, 'moda': 43},
|
||||||
|
"params": {
|
||||||
|
"entry_median": 75, "entry_sigma": 1.05, "entry_cap": 450,
|
||||||
|
"exit_median": 55, "exit_sigma": 1.05, "exit_cap": 420,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"disperso": {
|
||||||
|
"titulo": "Disperso",
|
||||||
|
"resumen": "Máxima variedad. Paga bastantes minutos por ella.",
|
||||||
|
"stats": {'coste': 7.87, 'madrugon': 2.99, 'mins': 6, 'moda': 29},
|
||||||
|
"params": {
|
||||||
|
"entry_median": 110, "entry_sigma": 1.15, "entry_cap": 600,
|
||||||
|
"exit_median": 80, "exit_sigma": 1.15, "exit_cap": 540,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sin_madrugar": {
|
||||||
|
"titulo": "Sin madrugar",
|
||||||
|
"resumen": "Entrada de la mañana pegada a las 9. La variedad se la come "
|
||||||
|
"la pausa de comer y la salida, así no te levantas antes.",
|
||||||
|
"stats": {'coste': 5.68, 'madrugon': 1.26, 'mins': 2, 'moda': 77},
|
||||||
|
"params": {
|
||||||
|
# tramo 0 = mañana: lo mas ajustado posible, no alarga el dia
|
||||||
|
"entry_median_w0": 35, "entry_sigma_w0": 0.70, "entry_cap_w0": 150,
|
||||||
|
# tramo 1 = vuelta de comer: generoso, solo acorta la comida
|
||||||
|
"entry_median_w1": 120, "entry_sigma_w1": 1.15, "entry_cap_w1": 600,
|
||||||
|
# salidas: generosas, no obligan a madrugar
|
||||||
|
"exit_median": 75, "exit_sigma": 1.10, "exit_cap": 480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFECTO = "natural"
|
||||||
|
|
||||||
|
|
||||||
|
def existe(nombre: str) -> bool:
|
||||||
|
return nombre in PERFILES
|
||||||
|
|
||||||
|
|
||||||
|
def nombres() -> list[str]:
|
||||||
|
return list(PERFILES)
|
||||||
|
|
||||||
|
|
||||||
|
def params(nombre: str) -> dict:
|
||||||
|
"""Parametros efectivos del perfil, con BASE por debajo."""
|
||||||
|
p = dict(BASE)
|
||||||
|
p.update(PERFILES.get(nombre, PERFILES[DEFECTO])["params"])
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def titulo(nombre: str) -> str:
|
||||||
|
return PERFILES.get(nombre, {}).get("titulo", nombre)
|
||||||
|
|
||||||
|
|
||||||
|
def resumen(nombre: str) -> str:
|
||||||
|
return PERFILES.get(nombre, {}).get("resumen", "")
|
||||||
|
|
||||||
|
|
||||||
|
def stats(nombre: str) -> dict:
|
||||||
|
"""Medidas sobre los 226 dias laborables del año. Ver tools/perfiles.py.
|
||||||
|
|
||||||
|
coste = presencia total de mas, min/dia
|
||||||
|
madrugon = de esos, los que te levantas antes por la entrada de la mañana
|
||||||
|
mins = minutos distintos visibles en la entrada de las 09:00
|
||||||
|
moda = cuota del minuto mas frecuente
|
||||||
|
"""
|
||||||
|
return PERFILES.get(nombre, {}).get("stats", {})
|
||||||
|
|
||||||
|
|
||||||
|
def valor(p: dict, clase: str, campo: str, wi: int):
|
||||||
|
"""`entry_median_w0` gana a `entry_median` para el tramo 0."""
|
||||||
|
return p.get(f"{clase}_{campo}_w{wi}", p[f"{clase}_{campo}"])
|
||||||
86
tools/perfiles.py
Normal file
86
tools/perfiles.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mide cada perfil de jitter sobre el calendario real del año. No ficha nada."""
|
||||||
|
import json, os, random, sys, tempfile
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from app import profiles, schedule, settings
|
||||||
|
from app.planner import ENTRADA, Planner
|
||||||
|
from app.store import Store
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CAL = os.path.join(ROOT, "data", "calendar_cache.json")
|
||||||
|
|
||||||
|
|
||||||
|
def medir(nombre, cal, cfg, semilla=11):
|
||||||
|
"""Devuelve (saldo, presencia_extra_min_dia, histograma_entrada, histograma_salida)."""
|
||||||
|
st = Store(os.path.join(tempfile.mkdtemp(), "m.db"))
|
||||||
|
st.put("perfil", nombre)
|
||||||
|
pl = Planner(cfg, st)
|
||||||
|
rng = random.Random(semilla)
|
||||||
|
lo, hi = cal.span
|
||||||
|
ent, sal = Counter(), Counter()
|
||||||
|
P = W = pres = dias = 0
|
||||||
|
peor = 10 ** 9
|
||||||
|
madrugon = 0 # minutos de mas en la ENTRADA DE LA MAÑANA (la que alarga el dia)
|
||||||
|
d = lo
|
||||||
|
while d <= hi:
|
||||||
|
if cal.is_workday(d):
|
||||||
|
plan = pl.plan_day(cal, d, rng)
|
||||||
|
dias += 1
|
||||||
|
for wi, w in enumerate(cal.windows(d)):
|
||||||
|
e = [x for x in plan if x.window_idx == wi and x.typ == ENTRADA][0]
|
||||||
|
s = [x for x in plan if x.window_idx == wi and x.typ != ENTRADA][0]
|
||||||
|
em = e.planned_at.hour * 60 + e.planned_at.minute
|
||||||
|
sm = s.planned_at.hour * 60 + s.planned_at.minute
|
||||||
|
W += min(sm, w.end) - max(em, w.start)
|
||||||
|
P += w.minutes
|
||||||
|
pres += sm - em
|
||||||
|
if wi == 0:
|
||||||
|
madrugon += w.start - em
|
||||||
|
if w.start == 540:
|
||||||
|
ent[e.planned_at.strftime("%H:%M")] += 1
|
||||||
|
if w.end == 1110:
|
||||||
|
sal[s.planned_at.strftime("%H:%M")] += 1
|
||||||
|
peor = min(peor, min(x.offset_seconds for x in plan))
|
||||||
|
d += timedelta(days=1)
|
||||||
|
st.close()
|
||||||
|
return dict(saldo=W - P, coste=(pres - P) / dias, madrugon=madrugon / dias,
|
||||||
|
peor=peor, ent=ent, sal=sal, dias=dias)
|
||||||
|
|
||||||
|
|
||||||
|
def barra(c, ancho=40):
|
||||||
|
t = sum(c.values())
|
||||||
|
vis = {m: n / t for m, n in sorted(c.items()) if n / t >= 0.03}
|
||||||
|
return vis, " ".join("%s %d%%" % (m, round(100 * p)) for m, p in vis.items())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = settings.load()
|
||||||
|
cal = schedule.Calendar(json.load(open(CAL)))
|
||||||
|
print("%-14s %6s %9s %10s %6s %6s" %
|
||||||
|
("perfil", "saldo", "min/día", "madrugón", "mins", "moda"))
|
||||||
|
print("-" * 62)
|
||||||
|
for n in profiles.nombres():
|
||||||
|
r = medir(n, cal, cfg)
|
||||||
|
ve, _ = barra(r["ent"])
|
||||||
|
marca = " ←" if n == profiles.DEFECTO else ""
|
||||||
|
print("%-14s %+5d %8.2f %9.2f %6d %5d%%%s" %
|
||||||
|
(n, r["saldo"], r["coste"], r["madrugon"], len(ve),
|
||||||
|
round(100 * max(ve.values())), marca))
|
||||||
|
print("\nmadrugón = minutos/día que te levantas antes por la entrada de la mañana")
|
||||||
|
print("min/día = presencia total de más (mañana + comida + salidas)\n")
|
||||||
|
|
||||||
|
for n in profiles.nombres():
|
||||||
|
r = medir(n, cal, cfg)
|
||||||
|
_, se = barra(r["ent"])
|
||||||
|
_, ss = barra(r["sal"])
|
||||||
|
print("=== %s === %s" % (profiles.titulo(n), profiles.resumen(n)))
|
||||||
|
print(" entrada 09:00 %s" % se)
|
||||||
|
print(" salida 18:30 %s" % ss)
|
||||||
|
print("tu histórico real: entrada 09:00 23% 09:01 8% 09:02 31% 09:03 38%")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user