walopy 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {walopy-0.4.2/src/walopy.egg-info → walopy-0.4.3}/PKG-INFO +1 -1
  2. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/__init__.py +1 -1
  3. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_catalogo_en.py +4 -0
  4. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_catalogo_es.py +4 -0
  5. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/solver.py +40 -9
  6. {walopy-0.4.2 → walopy-0.4.3/src/walopy.egg-info}/PKG-INFO +1 -1
  7. {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/SOURCES.txt +6 -0
  8. walopy-0.4.3/tests/test_dibujo_plotly.py +153 -0
  9. {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventario_i18n.py +4 -0
  10. walopy-0.4.3/tests/test_propiedades_algoritmos.py +194 -0
  11. walopy-0.4.3/tests/test_propiedades_inventario_fiabilidad.py +221 -0
  12. walopy-0.4.3/tests/test_servidores_cmax.py +59 -0
  13. walopy-0.4.3/tests/test_servidores_minimos.py +79 -0
  14. {walopy-0.4.2 → walopy-0.4.3}/tests/test_solver.py +1 -0
  15. walopy-0.4.3/tests/test_solver_contratos.py +142 -0
  16. {walopy-0.4.2 → walopy-0.4.3}/LICENSE +0 -0
  17. {walopy-0.4.2 → walopy-0.4.3}/README.md +0 -0
  18. {walopy-0.4.2 → walopy-0.4.3}/pyproject.toml +0 -0
  19. {walopy-0.4.2 → walopy-0.4.3}/setup.cfg +0 -0
  20. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/__main__.py +0 -0
  21. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_i18n.py +0 -0
  22. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/_utils.py +0 -0
  23. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/advanced.py +0 -0
  24. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/bottleneck.py +0 -0
  25. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/fitting.py +0 -0
  26. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/inventory.py +0 -0
  27. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/kpi.py +0 -0
  28. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/network.py +0 -0
  29. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/operations.py +0 -0
  30. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/plotting.py +0 -0
  31. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/project.py +0 -0
  32. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/py.typed +0 -0
  33. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/queuing.py +0 -0
  34. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/reliability.py +0 -0
  35. {walopy-0.4.2 → walopy-0.4.3}/src/walopy/scheduling.py +0 -0
  36. {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/dependency_links.txt +0 -0
  37. {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/requires.txt +0 -0
  38. {walopy-0.4.2 → walopy-0.4.3}/src/walopy.egg-info/top_level.txt +0 -0
  39. {walopy-0.4.2 → walopy-0.4.3}/tests/test_abc_xyz.py +0 -0
  40. {walopy-0.4.2 → walopy-0.4.3}/tests/test_advanced.py +0 -0
  41. {walopy-0.4.2 → walopy-0.4.3}/tests/test_bottleneck.py +0 -0
  42. {walopy-0.4.2 → walopy-0.4.3}/tests/test_cli.py +0 -0
  43. {walopy-0.4.2 → walopy-0.4.3}/tests/test_complejidad.py +0 -0
  44. {walopy-0.4.2 → walopy-0.4.3}/tests/test_contrato_entradas.py +0 -0
  45. {walopy-0.4.2 → walopy-0.4.3}/tests/test_cpm_pert.py +0 -0
  46. {walopy-0.4.2 → walopy-0.4.3}/tests/test_documentacion.py +0 -0
  47. {walopy-0.4.2 → walopy-0.4.3}/tests/test_documentacion_ingles.py +0 -0
  48. {walopy-0.4.2 → walopy-0.4.3}/tests/test_ejemplos.py +0 -0
  49. {walopy-0.4.2 → walopy-0.4.3}/tests/test_exchange_curve.py +0 -0
  50. {walopy-0.4.2 → walopy-0.4.3}/tests/test_fitting.py +0 -0
  51. {walopy-0.4.2 → walopy-0.4.3}/tests/test_i18n.py +0 -0
  52. {walopy-0.4.2 → walopy-0.4.3}/tests/test_i18n_paridad.py +0 -0
  53. {walopy-0.4.2 → walopy-0.4.3}/tests/test_idioma.py +0 -0
  54. {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventory.py +0 -0
  55. {walopy-0.4.2 → walopy-0.4.3}/tests/test_inventory_new.py +0 -0
  56. {walopy-0.4.2 → walopy-0.4.3}/tests/test_kpi.py +0 -0
  57. {walopy-0.4.2 → walopy-0.4.3}/tests/test_kpi_grafica.py +0 -0
  58. {walopy-0.4.2 → walopy-0.4.3}/tests/test_mg1.py +0 -0
  59. {walopy-0.4.2 → walopy-0.4.3}/tests/test_mmc_estable.py +0 -0
  60. {walopy-0.4.2 → walopy-0.4.3}/tests/test_mrp.py +0 -0
  61. {walopy-0.4.2 → walopy-0.4.3}/tests/test_neh_flowshop.py +0 -0
  62. {walopy-0.4.2 → walopy-0.4.3}/tests/test_network.py +0 -0
  63. {walopy-0.4.2 → walopy-0.4.3}/tests/test_new_models.py +0 -0
  64. {walopy-0.4.2 → walopy-0.4.3}/tests/test_notas_release.py +0 -0
  65. {walopy-0.4.2 → walopy-0.4.3}/tests/test_operations.py +0 -0
  66. {walopy-0.4.2 → walopy-0.4.3}/tests/test_plotting.py +0 -0
  67. {walopy-0.4.2 → walopy-0.4.3}/tests/test_politicas_inventario.py +0 -0
  68. {walopy-0.4.2 → walopy-0.4.3}/tests/test_propiedades_diferenciales.py +0 -0
  69. {walopy-0.4.2 → walopy-0.4.3}/tests/test_queuing.py +0 -0
  70. {walopy-0.4.2 → walopy-0.4.3}/tests/test_readme.py +0 -0
  71. {walopy-0.4.2 → walopy-0.4.3}/tests/test_referencias_externas.py +0 -0
  72. {walopy-0.4.2 → walopy-0.4.3}/tests/test_regresion_validacion.py +0 -0
  73. {walopy-0.4.2 → walopy-0.4.3}/tests/test_reliability.py +0 -0
  74. {walopy-0.4.2 → walopy-0.4.3}/tests/test_safety_stock_curve.py +0 -0
  75. {walopy-0.4.2 → walopy-0.4.3}/tests/test_salida_identica.py +0 -0
  76. {walopy-0.4.2 → walopy-0.4.3}/tests/test_salida_por_idioma.py +0 -0
  77. {walopy-0.4.2 → walopy-0.4.3}/tests/test_scheduling.py +0 -0
  78. {walopy-0.4.2 → walopy-0.4.3}/tests/test_scorecard.py +0 -0
  79. {walopy-0.4.2 → walopy-0.4.3}/tests/test_trazabilidad.py +0 -0
  80. {walopy-0.4.2 → walopy-0.4.3}/tests/test_version.py +0 -0
  81. {walopy-0.4.2 → walopy-0.4.3}/tests/test_wagner_whitin_ceros.py +0 -0
  82. {walopy-0.4.2 → walopy-0.4.3}/tests/test_weibull.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Herramientas de investigación de operaciones: colas, inventarios, scheduling, CPM/PERT, confiabilidad, OEE, cuellos de botella y árboles de KPI
5
5
  Author-email: LeoSanta15 <angelsanta1@gmail.com>
6
6
  License-Expression: MIT
@@ -164,7 +164,7 @@ from .solver import (
164
164
  solve_servers,
165
165
  )
166
166
 
167
- __version__ = "0.4.2" # fuente única de la versión; pyproject.toml la lee con [tool.setuptools.dynamic]
167
+ __version__ = "0.4.3" # fuente única de la versión; pyproject.toml la lee con [tool.setuptools.dynamic]
168
168
 
169
169
  __all__ = [
170
170
  # idioma de los textos
@@ -584,6 +584,10 @@ EN: dict[str, str] = {
584
584
  "solver.error.solve_lam.supera_incluso_muy_baja_objetivo": "{target_metric} exceeds {target_value} even with a very low λ. The target may be infeasible for this model / μ.",
585
585
  "solver.error.solve_mu.sigue_superando_objetivo_puede_ser": "{target_metric} still exceeds {target_value} with μ = {mu_max:.4g}. The target may be numerically infeasible.",
586
586
  "solver.error.solve_servers.pudo_cumplir_hasta_servidores_considere": "Could not meet {target_metric} ≤ {target_value} with up to {c_max} servers. Consider increasing μ or relaxing the target.",
587
+ "solver.error.optimize_servers.sin_servidores_estables_hasta_c_max": "With λ={lam} and μ={mu} at least {c_min} servers are needed for the system to be stable, but c_max={c_max}. Increase c_max.",
588
+ "solver.error.sensitivity.values_no_puede_estar_vacio": "values cannot be empty.",
589
+ "solver.error.sensitivity.param_no_es_parametro_modelo": "{param!r} is not a parameter of {modelo}.",
590
+ "solver.error.compare.sin_resultados": "compare needs at least one result.",
587
591
  "solver.etiqueta.compare.escenario": "scenario_{expr}",
588
592
  "solver.etiqueta.summary.convergio": "did NOT converge",
589
593
  "solver.etiqueta.summary.convergio_2": "converged",
@@ -584,6 +584,10 @@ ES: dict[str, str] = {
584
584
  "solver.error.solve_lam.supera_incluso_muy_baja_objetivo": "{target_metric} supera {target_value} incluso con λ muy baja. El objetivo puede ser infactible para este modelo / μ.",
585
585
  "solver.error.solve_mu.sigue_superando_objetivo_puede_ser": "{target_metric} sigue superando {target_value} con μ = {mu_max:.4g}. El objetivo puede ser numéricamente infactible.",
586
586
  "solver.error.solve_servers.pudo_cumplir_hasta_servidores_considere": "No se pudo cumplir {target_metric} ≤ {target_value} con hasta {c_max} servidores. Considere aumentar μ o relajar el objetivo.",
587
+ "solver.error.optimize_servers.sin_servidores_estables_hasta_c_max": "Con λ={lam} y μ={mu} hacen falta al menos {c_min} servidores para que el sistema sea estable, pero c_max={c_max}. Aumente c_max.",
588
+ "solver.error.sensitivity.values_no_puede_estar_vacio": "values no puede estar vacío.",
589
+ "solver.error.sensitivity.param_no_es_parametro_modelo": "{param!r} no es un parámetro de {modelo}.",
590
+ "solver.error.compare.sin_resultados": "compare necesita al menos un resultado.",
587
591
  "solver.etiqueta.compare.escenario": "escenario_{expr}",
588
592
  "solver.etiqueta.summary.convergio": "NO convergió",
589
593
  "solver.etiqueta.summary.convergio_2": "convergió",
@@ -1,6 +1,7 @@
1
1
  """Solvers: encuentran parámetros óptimos o metas definidas por la persona usuaria en modelos de colas y operaciones."""
2
2
  from __future__ import annotations
3
3
 
4
+ import inspect
4
5
  from collections.abc import Sequence
5
6
  from dataclasses import dataclass, field
6
7
  from typing import TYPE_CHECKING, Any, Callable
@@ -9,7 +10,7 @@ import numpy as np
9
10
  import pandas as pd
10
11
 
11
12
  from ._i18n import t as _t
12
- from ._utils import as_positive
13
+ from ._utils import MAX_SERVIDORES, as_int_positive, as_positive
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  import plotly.graph_objects as go
@@ -316,6 +317,14 @@ def solve_mu(
316
317
  # solve_servers — minimum c to meet a target
317
318
  # ---------------------------------------------------------------------------
318
319
 
320
+ def _min_servidores_estables(lam: float, mu: float) -> int:
321
+ """Menor número de servidores *c* con ρ = λ/(c·μ) < 1 (la misma condición que exige ``mmc``)."""
322
+ c = max(1, int(lam / mu))
323
+ while lam / (c * mu) >= 1.0:
324
+ c += 1
325
+ return c
326
+
327
+
319
328
  def solve_servers(
320
329
  target_metric: str,
321
330
  target_value: float,
@@ -355,15 +364,16 @@ def solve_servers(
355
364
  --------
356
365
  >>> r = solve_servers(target_metric='Wq', target_value=0.5, lam=4.0, mu=3.0)
357
366
  >>> round(r.value, 4)
358
- 3.0
367
+ 2.0
359
368
  """
360
369
  from .queuing import mmc
361
370
 
362
371
  lam = as_positive(lam, "lam")
363
372
  mu = as_positive(mu, "mu")
364
373
  target_value = as_positive(target_value, "target_value")
374
+ c_max = as_int_positive(c_max, "c_max", max=MAX_SERVIDORES)
365
375
 
366
- c_min_stable = int(np.ceil(lam / mu)) + 1 # min c for stability
376
+ c_min_stable = _min_servidores_estables(lam, mu)
367
377
 
368
378
  for c in range(c_min_stable, c_max + 1):
369
379
  result = mmc(lam, mu, c)
@@ -428,7 +438,7 @@ def optimize_servers(
428
438
  --------
429
439
  >>> r = optimize_servers(lam=4.0, mu=3.0, cost_per_server=10.0, cost_per_wait=5.0)
430
440
  >>> round(r.min_cost, 4)
431
- 30.7232
441
+ 25.3333
432
442
  """
433
443
  from .queuing import mmc
434
444
 
@@ -436,8 +446,11 @@ def optimize_servers(
436
446
  mu = as_positive(mu, "mu")
437
447
  cost_per_server = as_positive(cost_per_server, "cost_per_server")
438
448
  cost_per_wait = as_positive(cost_per_wait, "cost_per_wait")
449
+ c_max = as_int_positive(c_max, "c_max", max=MAX_SERVIDORES)
439
450
 
440
- c_min = int(np.ceil(lam / mu)) + 1
451
+ c_min = _min_servidores_estables(lam, mu)
452
+ if c_min > c_max:
453
+ raise ValueError(_t("solver.error.optimize_servers.sin_servidores_estables_hasta_c_max", lam=lam, mu=mu, c_min=c_min, c_max=c_max))
441
454
  rows: list[dict[str, Any]] = []
442
455
 
443
456
  for c in range(c_min, c_max + 1):
@@ -518,6 +531,15 @@ def sensitivity(
518
531
  >>> import numpy as np
519
532
  >>> df = sensitivity(mm1, "lam", np.linspace(0.5, 4.5, 20), mu=5.0)
520
533
  """
534
+ if len(values) == 0:
535
+ raise ValueError(_t("solver.error.sensitivity.values_no_puede_estar_vacio"))
536
+ try:
537
+ parametros = inspect.signature(model_fn).parameters
538
+ except (TypeError, ValueError): # callables sin firma inspeccionable: se intenta igualmente
539
+ parametros = None
540
+ if parametros is not None and param not in parametros and not any(p.kind is p.VAR_KEYWORD for p in parametros.values()):
541
+ nombre_modelo = repr(model_fn) if not hasattr(model_fn, "__name__") else model_fn.__name__
542
+ raise ValueError(_t("solver.error.sensitivity.param_no_es_parametro_modelo", param=param, modelo=nombre_modelo))
521
543
  rows: list[dict[str, Any]] = []
522
544
  for v in values:
523
545
  try:
@@ -601,6 +623,8 @@ def batch_model(
601
623
 
602
624
  # Pre-compute integer columns so iterrows() float-upcast can be reversed
603
625
  int_cols = {col for col in df.columns if pd.api.types.is_integer_dtype(df[col].dtype)}
626
+ # Una columna entera con NaN (DataFrame disperso) pasa a float64: sus valores enteros vuelven a ser int al llamar al modelo
627
+ sparse_int_cols = {col for col in df.columns if pd.api.types.is_float_dtype(df[col].dtype) and df[col].isna().any()}
604
628
 
605
629
  for _, row_series in df.iterrows():
606
630
  # Merge: fixed_kwargs as base, row values (non-NaN) take precedence
@@ -609,7 +633,10 @@ def batch_model(
609
633
  if isinstance(v, float) and np.isnan(v):
610
634
  continue
611
635
  # iterrows() upcasts int64 columns to float64; reverse that cast
612
- kwargs[k] = int(v) if k in int_cols else v
636
+ if k in int_cols or (k in sparse_int_cols and isinstance(v, (float, np.floating)) and float(v).is_integer()):
637
+ kwargs[k] = int(v)
638
+ else:
639
+ kwargs[k] = v
613
640
 
614
641
  try:
615
642
  result = model_fn(**kwargs)
@@ -655,7 +682,8 @@ def compare(
655
682
  ----------
656
683
  *results
657
684
  Cualquier objeto de resultado de walopy (``QueueResult``, ``SimulationResult``,
658
- ``EOQResult``, etc.) o diccionarios simples.
685
+ ``EOQResult``, etc.) o diccionarios simples. Si la tabla ``to_frame()`` de un resultado tiene varias filas
686
+ (pedidos, actividades…), se comparan sus campos escalares (costo total, duración del proyecto…).
659
687
  labels : sequence of str, optional
660
688
  Etiquetas de las filas. Por defecto ``'escenario_1'``, ``'escenario_2'``, …
661
689
 
@@ -680,11 +708,14 @@ def compare(
680
708
  >>> tabla["c (servidores)"].tolist()
681
709
  [1, 2]
682
710
  """
711
+ if len(results) == 0:
712
+ raise ValueError(_t("solver.error.compare.sin_resultados"))
683
713
  rows: list[dict[str, Any]] = []
684
714
  for i, r in enumerate(results):
685
715
  label = labels[i] if (labels and i < len(labels)) else _t("solver.etiqueta.compare.escenario", expr=i + 1)
686
- if hasattr(r, "to_frame"):
687
- row = r.to_frame().iloc[0].to_dict()
716
+ tabla = r.to_frame() if hasattr(r, "to_frame") else None
717
+ if tabla is not None and len(tabla) == 1:
718
+ row = tabla.iloc[0].to_dict()
688
719
  elif isinstance(r, dict):
689
720
  row = dict(r)
690
721
  elif hasattr(r, "__dict__"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Herramientas de investigación de operaciones: colas, inventarios, scheduling, CPM/PERT, confiabilidad, OEE, cuellos de botella y árboles de KPI
5
5
  Author-email: LeoSanta15 <angelsanta1@gmail.com>
6
6
  License-Expression: MIT
@@ -33,6 +33,7 @@ tests/test_cli.py
33
33
  tests/test_complejidad.py
34
34
  tests/test_contrato_entradas.py
35
35
  tests/test_cpm_pert.py
36
+ tests/test_dibujo_plotly.py
36
37
  tests/test_documentacion.py
37
38
  tests/test_documentacion_ingles.py
38
39
  tests/test_ejemplos.py
@@ -56,7 +57,9 @@ tests/test_notas_release.py
56
57
  tests/test_operations.py
57
58
  tests/test_plotting.py
58
59
  tests/test_politicas_inventario.py
60
+ tests/test_propiedades_algoritmos.py
59
61
  tests/test_propiedades_diferenciales.py
62
+ tests/test_propiedades_inventario_fiabilidad.py
60
63
  tests/test_queuing.py
61
64
  tests/test_readme.py
62
65
  tests/test_referencias_externas.py
@@ -67,7 +70,10 @@ tests/test_salida_identica.py
67
70
  tests/test_salida_por_idioma.py
68
71
  tests/test_scheduling.py
69
72
  tests/test_scorecard.py
73
+ tests/test_servidores_cmax.py
74
+ tests/test_servidores_minimos.py
70
75
  tests/test_solver.py
76
+ tests/test_solver_contratos.py
71
77
  tests/test_trazabilidad.py
72
78
  tests/test_version.py
73
79
  tests/test_wagner_whitin_ceros.py
@@ -0,0 +1,153 @@
1
+ """Las figuras de Plotly se dibujan, no solo existen como objeto (W-26).
2
+
3
+ El árbol de KPI de 0.4.0 tenía una ``Figure`` correcta para los tests y salía **en blanco** en el navegador. Aquí hay dos capas:
4
+
5
+ * **Siempre** (sin navegador): cada traza de cada figura tiene datos finitos que dibujar.
6
+ * **Con Chromium** (``WALOPY_DIBUJO=1``; ``make dibujo`` y el job «Dibujo» del CI): se renderiza la figura en un navegador sin interfaz y se compara
7
+ la **zona del gráfico** con la misma figura con las trazas invisibles (``opacity=0``). Medir solo esa zona y contra una base con los mismos ejes y
8
+ cuadrículas es lo que separa «dibujada» de «en blanco»: una captura completa tiene título y ejes aunque no haya datos (el árbol roto ocupa 1,1 %
9
+ de píxeles distintos de blanco en pantalla completa, y 0,00 % de píxeles distintos de la base en la zona del gráfico; los correctos, entre 1,3 % y 66 %).
10
+
11
+ Si ``WALOPY_DIBUJO=1`` y no hay navegador, los tests **fallan** (no se saltan), para que el CI no pase en silencio. Ruta explícita: ``WALOPY_CHROME``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import glob
17
+ import math
18
+ import os
19
+ import shutil
20
+ import subprocess # noqa: S404
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+
25
+ import walopy as wl
26
+ from walopy import plotting
27
+
28
+ go = pytest.importorskip("plotly.graph_objects")
29
+ np = pytest.importorskip("numpy")
30
+
31
+ ANCHO, ALTO = 800, 500
32
+ MARGEN = {"l": 60, "r": 30, "t": 60, "b": 60}
33
+ UMBRAL = 0.002 # fracción mínima de píxeles de la zona del gráfico que cambian al quitar los datos
34
+ EXIGIDO = os.environ.get("WALOPY_DIBUJO") == "1"
35
+
36
+ pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
37
+
38
+
39
+ def _ebitda() -> wl.KPINode:
40
+ a = wl.KPINode("Producto A", value=120_000, unit="€")
41
+ b = wl.KPINode("Producto B", value=80_000, unit="€")
42
+ ingresos = wl.KPINode("Ingresos", value=200_000, unit="€", children=[a, b])
43
+ costos = wl.KPINode("Costos de operación", value=100_000, unit="€")
44
+ return wl.KPINode("EBITDA", value=100_000, unit="€", children=[ingresos, costos])
45
+
46
+
47
+ def _contrato(nombre: str):
48
+ from tests.test_contrato_entradas import BASE
49
+
50
+ return getattr(wl, nombre)(**copy.deepcopy(BASE[nombre]))
51
+
52
+
53
+ FIGURAS = {
54
+ "kpi_ebitda_treemap": lambda: _ebitda().plot(),
55
+ "kpi_ebitda_sunburst": lambda: _ebitda().plot(kind="sunburst"),
56
+ "kpi_oee_treemap": lambda: wl.oee_kpi_tree(0.9, 0.8, 0.95).plot(),
57
+ "kpi_roi_sunburst": lambda: wl.roi_kpi_tree(50_000.0, 10_000.0, 8.0, 2_000.0, 20_000.0).plot(kind="sunburst"),
58
+ "optimize_servers": lambda: _contrato("optimize_servers").plot(),
59
+ "monte_carlo_gg1": lambda: _contrato("monte_carlo_gg1").plot(),
60
+ "line_balance": lambda: _contrato("line_balance").plot(),
61
+ "break_even": lambda: _contrato("break_even").plot(),
62
+ "break_even_sales": lambda: _contrato("break_even_sales").plot(),
63
+ "sensitivity": lambda: plotting.plot_sensitivity(wl.sensitivity(wl.mm1, "lam", [1.0, 1.5, 2.0, 2.5], mu=3.0), "lam"),
64
+ "queue_distribution": lambda: plotting.plot_queue_distribution(
65
+ wl.queue_length_pmf(2.0, 3.0, n_max=10), wl.sojourn_cdf(2.0, 3.0, n_points=20)
66
+ ),
67
+ }
68
+
69
+
70
+ def _arreglos(traza) -> list:
71
+ """Matrices que la traza dibuja: ``x``/``y`` (barras, líneas) o ``values`` (árboles)."""
72
+ return [getattr(traza, k) for k in ("x", "y", "values") if getattr(traza, k, None) is not None]
73
+
74
+
75
+ @pytest.mark.parametrize("nombre", sorted(FIGURAS))
76
+ def test_cada_traza_tiene_datos_finitos_que_dibujar(nombre):
77
+ fig = FIGURAS[nombre]()
78
+ assert len(fig.data) > 0, nombre
79
+ for i, traza in enumerate(fig.data):
80
+ valores = [float(v) for a in _arreglos(traza) for v in a if isinstance(v, (int, float, np.integer, np.floating))]
81
+ assert any(math.isfinite(v) for v in valores), f"{nombre}: la traza {i} ({traza.type}) no tiene valores finitos"
82
+
83
+
84
+ # ---------------------------------------------------------------------------------------------- navegador
85
+ def _chrome() -> str | None:
86
+ if os.environ.get("WALOPY_CHROME"):
87
+ return os.environ["WALOPY_CHROME"]
88
+ for cmd in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
89
+ ruta = shutil.which(cmd)
90
+ if ruta:
91
+ return ruta
92
+ candidatos = sorted(glob.glob("/opt/pw-browsers/chromium-*/chrome-linux/chrome"))
93
+ return candidatos[-1] if candidatos else None
94
+
95
+
96
+ @pytest.fixture(scope="module")
97
+ def navegador() -> str:
98
+ if not EXIGIDO:
99
+ pytest.skip("renderizado en navegador: ejecuta con WALOPY_DIBUJO=1 (make dibujo)")
100
+ ruta = _chrome()
101
+ assert ruta, "WALOPY_DIBUJO=1 pero no hay Chromium/Chrome (define WALOPY_CHROME)"
102
+ return ruta
103
+
104
+
105
+ def _preparar(fig):
106
+ f = go.Figure(fig)
107
+ f.update_layout(
108
+ width=ANCHO, height=ALTO, margin=MARGEN, showlegend=False, title=None, paper_bgcolor="white", plot_bgcolor="white"
109
+ )
110
+ return f
111
+
112
+
113
+ def _invisible(fig):
114
+ f = go.Figure(fig)
115
+ for traza in f.data:
116
+ traza.opacity = 0
117
+ return f
118
+
119
+
120
+ def _renderizar(chrome: str, fig, carpeta: Path) -> np.ndarray:
121
+ import matplotlib.image as mpimg
122
+
123
+ html, png = carpeta / "figura.html", carpeta / "figura.png"
124
+ png.unlink(missing_ok=True)
125
+ fig.write_html(str(html), include_plotlyjs=True, full_html=True, config={"displayModeBar": False, "staticPlot": True})
126
+ orden = [
127
+ chrome, "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", f"--window-size={ANCHO},{ALTO}",
128
+ "--virtual-time-budget=4000", f"--screenshot={png}", html.as_uri(),
129
+ ]
130
+ subprocess.run(orden, capture_output=True, timeout=120, check=True) # noqa: S603
131
+ return np.asarray(mpimg.imread(str(png)))[..., :3]
132
+
133
+
134
+ def _fraccion_dibujada(chrome: str, fig, carpeta: Path) -> float:
135
+ con = _renderizar(chrome, _preparar(fig), carpeta)
136
+ sin = _renderizar(chrome, _preparar(_invisible(fig)), carpeta)
137
+ zona = (slice(MARGEN["t"], ALTO - MARGEN["b"]), slice(MARGEN["l"], ANCHO - MARGEN["r"]))
138
+ return float((np.abs(con[zona] - sin[zona]).max(axis=2) > 0.1).mean())
139
+
140
+
141
+ @pytest.mark.parametrize("nombre", sorted(FIGURAS))
142
+ def test_la_figura_se_dibuja_en_el_navegador(nombre, navegador, tmp_path):
143
+ fraccion = _fraccion_dibujada(navegador, FIGURAS[nombre](), tmp_path)
144
+ assert fraccion > UMBRAL, f"{nombre}: la zona del gráfico solo cambia un {fraccion:.4%} respecto de la figura sin datos (¿en blanco?)"
145
+
146
+
147
+ def test_el_detector_ve_en_blanco_el_arbol_de_kpi_de_0_4_0(navegador, tmp_path):
148
+ """Control: con un padre menor que la suma de sus hijos (el defecto W-26) Plotly no dibuja y la medida tiene que quedar bajo el umbral."""
149
+ fig = go.Figure(_ebitda().plot())
150
+ valores = list(fig.data[0].values)
151
+ valores[0] = 1e-9 # raíz menor que la suma de sus hijos
152
+ fig.data[0].values = valores
153
+ assert _fraccion_dibujada(navegador, fig, tmp_path) <= UMBRAL
@@ -131,6 +131,10 @@ def test_el_catalogo_es_contiene_todas_las_claves_de_la_base_con_su_texto():
131
131
  # Textos añadidos después de v0.3.0 (no están en la línea base congelada): una clave nueva debe declararse aquí a propósito.
132
132
  AÑADIDAS_TRAS_V030 = {
133
133
  "i18n.error.validar.idioma_no_admitido",
134
+ "solver.error.optimize_servers.sin_servidores_estables_hasta_c_max", # W-29
135
+ "solver.error.sensitivity.values_no_puede_estar_vacio", # W-32
136
+ "solver.error.sensitivity.param_no_es_parametro_modelo",
137
+ "solver.error.compare.sin_resultados", # W-31
134
138
  "project.cabecera.to_frame.ht", # «HT»/«HL» (holgura total/libre): acrónimos españoles que el extractor no veía; ya existían en v0.3.0
135
139
  "project.cabecera.to_frame.hl",
136
140
  }
@@ -0,0 +1,194 @@
1
+ """Algoritmos de secuenciación, redes, colas finitas, lotes y MRP contra oráculos independientes (escenarios aleatorios con semilla fija).
2
+
3
+ Cada oráculo está escrito aparte del código de ``walopy`` (fuerza bruta, camino más largo, ecuaciones de balance, fórmula cerrada), y se comprueba que
4
+ discrimina: NEH, que es una heurística, se compara con el óptimo solo como cota. Carga moderada (``N`` escenarios por familia).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import itertools
9
+ import math
10
+ import random
11
+ import warnings
12
+
13
+ import walopy as wl
14
+
15
+ N = 120
16
+ SEMILLA = 20261008
17
+
18
+
19
+ def _cerca(a: float, b: float, rel: float = 1e-7) -> bool:
20
+ return math.isclose(a, b, rel_tol=rel, abs_tol=1e-9)
21
+
22
+
23
+ def _escenarios(familia: str):
24
+ r = random.Random(f"{SEMILLA}-{familia}")
25
+ return [random.Random(r.random()) for _ in range(N)]
26
+
27
+
28
+ def _makespan(tiempos, perm):
29
+ c = [0.0] * len(tiempos[0])
30
+ for j in perm:
31
+ c[0] += tiempos[j][0]
32
+ for k in range(1, len(c)):
33
+ c[k] = max(c[k], c[k - 1]) + tiempos[j][k]
34
+ return c[-1]
35
+
36
+
37
+ def _indices(secuencia):
38
+ return [int(s[1:]) - 1 for s in secuencia]
39
+
40
+
41
+ def test_neh_devuelve_una_permutacion_coherente_y_no_mejor_que_el_optimo():
42
+ for r in _escenarios("neh"):
43
+ n, m = r.randint(2, 6), r.randint(2, 4)
44
+ T = [[r.randint(1, 20) for _ in range(m)] for _ in range(n)]
45
+ res = wl.neh_flowshop(T)
46
+ sec = _indices(res.sequence)
47
+ optimo = min(_makespan(T, p) for p in itertools.permutations(range(n)))
48
+ assert sorted(sec) == list(range(n)), T
49
+ assert _cerca(res.makespan, _makespan(T, sec)), T
50
+ assert res.makespan >= optimo - 1e-9, T
51
+
52
+
53
+ def test_johnson_es_optimo_con_dos_maquinas():
54
+ for r in _escenarios("johnson"):
55
+ n = r.randint(2, 6)
56
+ a, b = [r.randint(1, 20) for _ in range(n)], [r.randint(1, 20) for _ in range(n)]
57
+ T = [[x, y] for x, y in zip(a, b)]
58
+ res = wl.johnson_flowshop(a, b)
59
+ optimo = min(_makespan(T, p) for p in itertools.permutations(range(n)))
60
+ assert _cerca(res.makespan, optimo), (a, b)
61
+ assert _cerca(_makespan(T, _indices(res.sequence)), optimo), (a, b)
62
+
63
+
64
+ def _terminaciones(p, perm):
65
+ t, out = 0, {}
66
+ for j in perm:
67
+ t += p[j]
68
+ out[j] = t
69
+ return out
70
+
71
+
72
+ def _camino_mas_largo(acts, i, memo):
73
+ if i not in memo:
74
+ previos = [_camino_mas_largo(acts, int(q[1:]), memo) for q in acts[i]["predecessors"]]
75
+ memo[i] = acts[i]["duration"] + max(previos or [0])
76
+ return memo[i]
77
+
78
+
79
+ def test_reglas_de_secuenciacion_en_una_maquina_son_optimas_en_su_criterio():
80
+ for r in _escenarios("una_maquina"):
81
+ n = r.randint(2, 6)
82
+ p = [r.randint(1, 15) for _ in range(n)]
83
+ d = [r.randint(1, 40) for _ in range(n)]
84
+ w = [r.randint(1, 5) for _ in range(n)]
85
+ perms = list(itertools.permutations(range(n)))
86
+
87
+ total = min(sum(_terminaciones(p, q).values()) for q in perms)
88
+ ponderado = min(sum(w[j] * c for j, c in _terminaciones(p, q).items()) for q in perms)
89
+ retraso = min(max(c - d[j] for j, c in _terminaciones(p, q).items()) for q in perms)
90
+ assert _cerca(wl.schedule_single(p, rule="SPT").total_completion_time, total), p
91
+ assert _cerca(wl.schedule_single(p, rule="WSPT", weights=w).total_weighted_completion_time, ponderado), (p, w)
92
+ assert _cerca(wl.schedule_single(p, rule="EDD", due_dates=d).max_lateness, retraso), (p, d)
93
+
94
+
95
+ def test_cpm_la_duracion_es_el_camino_mas_largo():
96
+ for r in _escenarios("cpm"):
97
+ na = r.randint(1, 9)
98
+ acts = [
99
+ {"name": f"A{i}", "duration": r.randint(1, 12), "predecessors": [f"A{j}" for j in range(i) if r.random() < 0.35]}
100
+ for i in range(na)
101
+ ]
102
+ memo: dict[int, int] = {}
103
+ assert _cerca(wl.cpm(acts).project_duration, max(_camino_mas_largo(acts, i, memo) for i in range(na))), acts
104
+
105
+
106
+ def test_colas_finitas_coinciden_con_las_ecuaciones_de_balance():
107
+ for r in _escenarios("mmck"):
108
+ c = r.randint(1, 5)
109
+ K = c + r.randint(0, 10)
110
+ mu = r.uniform(0.5, 10)
111
+ lam = r.uniform(0.1, 2.0) * c * mu
112
+ pesos = [1.0]
113
+ for k in range(1, K + 1):
114
+ pesos.append(pesos[-1] * lam / (min(k, c) * mu))
115
+ pi = [x / sum(pesos) for x in pesos]
116
+ L = sum(k * pi[k] for k in range(K + 1))
117
+ Lq = sum((k - c) * pi[k] for k in range(c + 1, K + 1))
118
+ q = wl.mmck(lam, mu, c, K)
119
+ assert _cerca(q.L, L, 1e-6) and _cerca(q.Lq, Lq, 1e-6), (lam, mu, c, K)
120
+ assert _cerca(q.W, L / (lam * (1 - pi[K])), 1e-6), (lam, mu, c, K)
121
+ q1, qc = wl.mm1k(lam, mu, K), wl.mmck(lam, mu, 1, K)
122
+ assert _cerca(q1.L, qc.L) and _cerca(q1.W, qc.W), (lam, mu, K)
123
+
124
+
125
+ def test_prioridades_conservan_el_numero_total_en_el_sistema():
126
+ for r in _escenarios("prioridad"):
127
+ lams = [r.uniform(0.05, 0.8) for _ in range(r.randint(2, 4))]
128
+ mu = sum(lams) / r.uniform(0.1, 0.95)
129
+ pr = wl.mm1_priority(lams, mu)
130
+ assert _cerca(sum(c["L"] for c in pr.classes), wl.mm1(sum(lams), mu).L, 1e-6), (lams, mu)
131
+
132
+
133
+ def _phi(z: float) -> float:
134
+ return math.exp(-z * z / 2) / math.sqrt(2 * math.pi)
135
+
136
+
137
+ def _gran_phi(z: float) -> float:
138
+ return 0.5 * (1 + math.erf(z / math.sqrt(2)))
139
+
140
+
141
+ def _ganancia(Q, media, sd, precio, costo, rescate):
142
+ """Ganancia esperada con demanda normal: precio·E[ventas] + rescate·E[sobrante] − costo·Q."""
143
+ z = (Q - media) / sd
144
+ ventas = media - sd * (_phi(z) - z * (1 - _gran_phi(z)))
145
+ return precio * ventas + rescate * (Q - ventas) - costo * Q
146
+
147
+
148
+ def test_newsvendor_el_optimo_maximiza_la_ganancia_esperada():
149
+ for r in _escenarios("newsvendor"):
150
+ media = r.uniform(20, 500)
151
+ sd = media * r.uniform(0.05, 0.3)
152
+ precio = r.uniform(5, 30)
153
+ costo = precio * r.uniform(0.1, 0.8)
154
+ rescate = costo * r.uniform(0, 0.6)
155
+
156
+ datos = (media, sd, precio, costo, rescate)
157
+ res = wl.newsvendor(media, sd, precio, costo, salvage=rescate)
158
+ rejilla = [media * (0.2 + i * 0.01) for i in range(300)]
159
+ assert _ganancia(res.optimal_qty, *datos) >= max(_ganancia(g, *datos) for g in rejilla) - 1e-6, datos
160
+ assert _cerca(res.expected_profit, _ganancia(res.optimal_qty, *datos), 1e-6), datos
161
+
162
+
163
+ def test_politicas_de_lotes_son_coherentes_con_sus_pedidos():
164
+ for r in _escenarios("lotes"):
165
+ dem = [r.choice([0, 0, r.uniform(1, 200)]) for _ in range(r.randint(1, 12))]
166
+ S, h = r.uniform(1, 500), r.uniform(0.05, 5)
167
+ for fn in (wl.silver_meal, wl.lot_for_lot):
168
+ res = fn(dem, S, h)
169
+ costo = len(res.orders) * S + h * sum(
170
+ dem[t - 1] * (t - o["period"]) for o in res.orders for t in o["covers_periods"]
171
+ )
172
+ assert all(o["order_qty"] > 0 for o in res.orders), (fn.__name__, dem)
173
+ assert _cerca(sum(o["order_qty"] for o in res.orders), sum(dem), 1e-9), (fn.__name__, dem)
174
+ assert _cerca(res.total_cost, costo, 1e-9), (fn.__name__, dem, S, h)
175
+ assert res.n_orders == len(res.orders), (fn.__name__, dem)
176
+
177
+
178
+ def test_mrp_cumple_el_balance_de_inventario():
179
+ for r in _escenarios("mrp"):
180
+ T = r.randint(2, 10)
181
+ bruto = [r.choice([0, r.randint(1, 50)]) for _ in range(T)]
182
+ inicial, lt, ss = r.randint(0, 60), r.randint(0, 3), r.randint(0, 10)
183
+ with warnings.catch_warnings():
184
+ warnings.simplefilter("ignore", UserWarning) # liberaciones vencidas (W-16): aviso esperado con lead_time largo
185
+ res = wl.mrp(bruto, initial_on_hand=inicial, lead_time=lt, safety_stock=ss)
186
+ previo = inicial
187
+ for t in range(T):
188
+ esperado = previo + res.scheduled_receipts[t] + res.planned_receipts[t] - bruto[t]
189
+ assert _cerca(res.projected_on_hand[t], esperado, 1e-9), (bruto, inicial, lt, ss, t)
190
+ assert res.projected_on_hand[t] >= ss - 1e-9, (bruto, inicial, lt, ss, t)
191
+ sin_recepcion = previo + res.scheduled_receipts[t] - bruto[t]
192
+ if res.planned_receipts[t] > 1e-9:
193
+ assert sin_recepcion < ss - 1e-9, (bruto, inicial, lt, ss, t) # solo se recibe lo necesario (LFL)
194
+ previo = res.projected_on_hand[t]