walopy 0.4.0__tar.gz → 0.4.2__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 (76) hide show
  1. {walopy-0.4.0/src/walopy.egg-info → walopy-0.4.2}/PKG-INFO +4 -1
  2. {walopy-0.4.0 → walopy-0.4.2}/README.md +3 -0
  3. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/__init__.py +1 -1
  4. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/inventory.py +13 -2
  5. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/plotting.py +18 -7
  6. {walopy-0.4.0 → walopy-0.4.2/src/walopy.egg-info}/PKG-INFO +4 -1
  7. {walopy-0.4.0 → walopy-0.4.2}/src/walopy.egg-info/SOURCES.txt +3 -0
  8. {walopy-0.4.0 → walopy-0.4.2}/tests/test_complejidad.py +11 -4
  9. walopy-0.4.2/tests/test_kpi_grafica.py +82 -0
  10. walopy-0.4.2/tests/test_propiedades_diferenciales.py +118 -0
  11. {walopy-0.4.0 → walopy-0.4.2}/tests/test_salida_identica.py +3 -0
  12. walopy-0.4.2/tests/test_wagner_whitin_ceros.py +61 -0
  13. {walopy-0.4.0 → walopy-0.4.2}/LICENSE +0 -0
  14. {walopy-0.4.0 → walopy-0.4.2}/pyproject.toml +0 -0
  15. {walopy-0.4.0 → walopy-0.4.2}/setup.cfg +0 -0
  16. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/__main__.py +0 -0
  17. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/_catalogo_en.py +0 -0
  18. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/_catalogo_es.py +0 -0
  19. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/_i18n.py +0 -0
  20. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/_utils.py +0 -0
  21. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/advanced.py +0 -0
  22. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/bottleneck.py +0 -0
  23. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/fitting.py +0 -0
  24. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/kpi.py +0 -0
  25. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/network.py +0 -0
  26. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/operations.py +0 -0
  27. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/project.py +0 -0
  28. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/py.typed +0 -0
  29. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/queuing.py +0 -0
  30. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/reliability.py +0 -0
  31. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/scheduling.py +0 -0
  32. {walopy-0.4.0 → walopy-0.4.2}/src/walopy/solver.py +0 -0
  33. {walopy-0.4.0 → walopy-0.4.2}/src/walopy.egg-info/dependency_links.txt +0 -0
  34. {walopy-0.4.0 → walopy-0.4.2}/src/walopy.egg-info/requires.txt +0 -0
  35. {walopy-0.4.0 → walopy-0.4.2}/src/walopy.egg-info/top_level.txt +0 -0
  36. {walopy-0.4.0 → walopy-0.4.2}/tests/test_abc_xyz.py +0 -0
  37. {walopy-0.4.0 → walopy-0.4.2}/tests/test_advanced.py +0 -0
  38. {walopy-0.4.0 → walopy-0.4.2}/tests/test_bottleneck.py +0 -0
  39. {walopy-0.4.0 → walopy-0.4.2}/tests/test_cli.py +0 -0
  40. {walopy-0.4.0 → walopy-0.4.2}/tests/test_contrato_entradas.py +0 -0
  41. {walopy-0.4.0 → walopy-0.4.2}/tests/test_cpm_pert.py +0 -0
  42. {walopy-0.4.0 → walopy-0.4.2}/tests/test_documentacion.py +0 -0
  43. {walopy-0.4.0 → walopy-0.4.2}/tests/test_documentacion_ingles.py +0 -0
  44. {walopy-0.4.0 → walopy-0.4.2}/tests/test_ejemplos.py +0 -0
  45. {walopy-0.4.0 → walopy-0.4.2}/tests/test_exchange_curve.py +0 -0
  46. {walopy-0.4.0 → walopy-0.4.2}/tests/test_fitting.py +0 -0
  47. {walopy-0.4.0 → walopy-0.4.2}/tests/test_i18n.py +0 -0
  48. {walopy-0.4.0 → walopy-0.4.2}/tests/test_i18n_paridad.py +0 -0
  49. {walopy-0.4.0 → walopy-0.4.2}/tests/test_idioma.py +0 -0
  50. {walopy-0.4.0 → walopy-0.4.2}/tests/test_inventario_i18n.py +0 -0
  51. {walopy-0.4.0 → walopy-0.4.2}/tests/test_inventory.py +0 -0
  52. {walopy-0.4.0 → walopy-0.4.2}/tests/test_inventory_new.py +0 -0
  53. {walopy-0.4.0 → walopy-0.4.2}/tests/test_kpi.py +0 -0
  54. {walopy-0.4.0 → walopy-0.4.2}/tests/test_mg1.py +0 -0
  55. {walopy-0.4.0 → walopy-0.4.2}/tests/test_mmc_estable.py +0 -0
  56. {walopy-0.4.0 → walopy-0.4.2}/tests/test_mrp.py +0 -0
  57. {walopy-0.4.0 → walopy-0.4.2}/tests/test_neh_flowshop.py +0 -0
  58. {walopy-0.4.0 → walopy-0.4.2}/tests/test_network.py +0 -0
  59. {walopy-0.4.0 → walopy-0.4.2}/tests/test_new_models.py +0 -0
  60. {walopy-0.4.0 → walopy-0.4.2}/tests/test_notas_release.py +0 -0
  61. {walopy-0.4.0 → walopy-0.4.2}/tests/test_operations.py +0 -0
  62. {walopy-0.4.0 → walopy-0.4.2}/tests/test_plotting.py +0 -0
  63. {walopy-0.4.0 → walopy-0.4.2}/tests/test_politicas_inventario.py +0 -0
  64. {walopy-0.4.0 → walopy-0.4.2}/tests/test_queuing.py +0 -0
  65. {walopy-0.4.0 → walopy-0.4.2}/tests/test_readme.py +0 -0
  66. {walopy-0.4.0 → walopy-0.4.2}/tests/test_referencias_externas.py +0 -0
  67. {walopy-0.4.0 → walopy-0.4.2}/tests/test_regresion_validacion.py +0 -0
  68. {walopy-0.4.0 → walopy-0.4.2}/tests/test_reliability.py +0 -0
  69. {walopy-0.4.0 → walopy-0.4.2}/tests/test_safety_stock_curve.py +0 -0
  70. {walopy-0.4.0 → walopy-0.4.2}/tests/test_salida_por_idioma.py +0 -0
  71. {walopy-0.4.0 → walopy-0.4.2}/tests/test_scheduling.py +0 -0
  72. {walopy-0.4.0 → walopy-0.4.2}/tests/test_scorecard.py +0 -0
  73. {walopy-0.4.0 → walopy-0.4.2}/tests/test_solver.py +0 -0
  74. {walopy-0.4.0 → walopy-0.4.2}/tests/test_trazabilidad.py +0 -0
  75. {walopy-0.4.0 → walopy-0.4.2}/tests/test_version.py +0 -0
  76. {walopy-0.4.0 → walopy-0.4.2}/tests/test_weibull.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -1563,6 +1563,9 @@ tree.plot()
1563
1563
 
1564
1564
  ### `KPINode` — árbol personalizado
1565
1565
 
1566
+ El rótulo de cada rectángulo es el valor real del nodo; su **tamaño** es el mayor entre ese valor y la suma de sus hijos (un KPI como EBITDA = Ingresos − Costos no
1567
+ es la suma de sus hijos, y Plotly dibuja en blanco un padre menor que ellos).
1568
+
1566
1569
  ```python
1567
1570
  rev = wl.KPINode("Revenue", value=200_000, unit="€", children=[
1568
1571
  wl.KPINode("Product A", value=120_000, unit="€"),
@@ -1515,6 +1515,9 @@ tree.plot()
1515
1515
 
1516
1516
  ### `KPINode` — árbol personalizado
1517
1517
 
1518
+ El rótulo de cada rectángulo es el valor real del nodo; su **tamaño** es el mayor entre ese valor y la suma de sus hijos (un KPI como EBITDA = Ingresos − Costos no
1519
+ es la suma de sus hijos, y Plotly dibuja en blanco un padre menor que ellos).
1520
+
1518
1521
  ```python
1519
1522
  rev = wl.KPINode("Revenue", value=200_000, unit="€", children=[
1520
1523
  wl.KPINode("Product A", value=120_000, unit="€"),
@@ -164,7 +164,7 @@ from .solver import (
164
164
  solve_servers,
165
165
  )
166
166
 
167
- __version__ = "0.4.0" # fuente única de la versión; pyproject.toml la lee con [tool.setuptools.dynamic]
167
+ __version__ = "0.4.2" # 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
@@ -1455,13 +1455,21 @@ def wagner_whitin(
1455
1455
  n = len(demands_v)
1456
1456
 
1457
1457
  INF = float("inf")
1458
- dp = [INF] * (n + 1) # dp[j] = min cost to satisfy demands 0..j-1
1458
+ dp = [INF] * (n + 1) # dp[j] = costo mínimo de satisfacer las demandas 1..j
1459
1459
  dp[0] = 0.0
1460
- last = [-1] * (n + 1) # last[j] = period i where order was placed
1460
+ last = [-1] * (n + 1) # last[j] = periodo del último pedido (0 = ninguno: demanda nula)
1461
1461
 
1462
1462
  # Recursión hacia adelante: el costo de mantener se acumula de forma incremental
1463
1463
  # (O(n²) en total; antes se recalculaba la suma completa para cada par (i, j): O(n³)).
1464
+ # Solo se pide en periodos con demanda positiva: pedir antes de que haga falta nunca
1465
+ # abarata el plan, y un periodo de demanda nula no justifica pagar la preparación.
1464
1466
  for i in range(1, n + 1):
1467
+ if demands_v[i - 1] == 0:
1468
+ # Sin demanda no hay pedido: se arrastra el costo del periodo anterior.
1469
+ if dp[i - 1] < dp[i]:
1470
+ dp[i] = dp[i - 1]
1471
+ last[i] = 0
1472
+ continue
1465
1473
  acum = 0.0 # Σ_{k=i..j} (k - i) · d_k
1466
1474
  for j in range(i, n + 1):
1467
1475
  acum += (j - i) * demands_v[j - 1]
@@ -1476,6 +1484,9 @@ def wagner_whitin(
1476
1484
  j = n
1477
1485
  while j > 0:
1478
1486
  i = last[j]
1487
+ if i == 0: # periodo sin demanda ni pedido
1488
+ j -= 1
1489
+ continue
1479
1490
  qty = sum(demands_v[k - 1] for k in range(i, j + 1))
1480
1491
  orders.append({
1481
1492
  "period": i,
@@ -193,6 +193,9 @@ def plot_kpi_tree(
193
193
  ) -> go.Figure:
194
194
  """Árbol de KPI interactivo con Plotly.
195
195
 
196
+ El rectángulo de cada nodo rotula su valor real; su tamaño es el mayor entre ese valor y la suma de los tamaños de sus hijos (un KPI como
197
+ EBITDA = Ingresos − Costos no es la suma de sus hijos, y Plotly no dibuja un padre menor que ellos).
198
+
196
199
  Parameters
197
200
  ----------
198
201
  root : KPINode
@@ -214,20 +217,26 @@ def plot_kpi_tree(
214
217
  labels: list[str] = []
215
218
  parents: list[str] = []
216
219
  values: list[float] = []
220
+ shown: list[str] = [] # valor real de cada nodo (el que se rotula)
217
221
  hover: list[str] = []
218
222
 
219
- def _collect(node: KPINode, parent_id: str = "") -> None:
223
+ def _collect(node: KPINode, parent_id: str = "") -> float:
220
224
  node_id = f"{parent_id}/{node.name}" if parent_id else node.name
225
+ pos = len(ids)
221
226
  ids.append(node_id)
222
227
  labels.append(node.name)
223
228
  parents.append(parent_id)
224
- # Use absolute value for area sizing; zero would collapse the tile
225
- values.append(max(abs(node.value), 1e-9))
229
+ values.append(0.0)
230
+ shown.append(f"{node.value:,.8g}")
226
231
  unit_str = f" {node.unit}" if node.unit else ""
227
232
  formula_str = _t("plotting.grafica.collect.br_2", formula=node.formula) if node.formula else ""
228
233
  hover.append(_t("plotting.grafica.collect.br", name=node.name, value=node.value, unit_str=unit_str, formula_str=formula_str))
229
- for child in node.children:
230
- _collect(child, node_id)
234
+ children_size = sum(_collect(child, node_id) for child in node.children)
235
+ # Tamaño del rectángulo: con branchvalues="total" Plotly exige que el padre valga al menos la suma de sus hijos, y si no lo
236
+ # cumple dibuja la figura en blanco. Un KPI no es la suma de sus hijos (EBITDA = Ingresos − Costos; OEE = A × P × Q), así que
237
+ # el tamaño del padre es max(su valor, la suma de los tamaños de sus hijos); el valor rotulado sigue siendo el del nodo.
238
+ values[pos] = max(abs(node.value), children_size, 1e-9)
239
+ return values[pos]
231
240
 
232
241
  _collect(root)
233
242
 
@@ -239,10 +248,11 @@ def plot_kpi_tree(
239
248
  labels=labels,
240
249
  parents=parents,
241
250
  values=values,
251
+ text=shown,
242
252
  customdata=hover,
243
253
  hovertemplate=_t("plotting.grafica.plot_kpi_tree.customdata_extra_extra"),
244
254
  branchvalues="total",
245
- textinfo="label+value",
255
+ texttemplate="%{label}<br>%{text}",
246
256
  insidetextorientation="radial",
247
257
  marker=dict(colorscale="Blues"),
248
258
  )
@@ -256,10 +266,11 @@ def plot_kpi_tree(
256
266
  labels=labels,
257
267
  parents=parents,
258
268
  values=values,
269
+ text=shown,
259
270
  customdata=hover,
260
271
  hovertemplate=_t("plotting.grafica.plot_kpi_tree.customdata_extra_extra"),
261
272
  branchvalues="total",
262
- texttemplate="<b>%{label}</b><br>%{value:.4g}",
273
+ texttemplate="<b>%{label}</b><br>%{text}",
263
274
  textfont=dict(size=13),
264
275
  marker=dict(
265
276
  colorscale="Blues",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -1563,6 +1563,9 @@ tree.plot()
1563
1563
 
1564
1564
  ### `KPINode` — árbol personalizado
1565
1565
 
1566
+ El rótulo de cada rectángulo es el valor real del nodo; su **tamaño** es el mayor entre ese valor y la suma de sus hijos (un KPI como EBITDA = Ingresos − Costos no
1567
+ es la suma de sus hijos, y Plotly dibuja en blanco un padre menor que ellos).
1568
+
1566
1569
  ```python
1567
1570
  rev = wl.KPINode("Revenue", value=200_000, unit="€", children=[
1568
1571
  wl.KPINode("Product A", value=120_000, unit="€"),
@@ -45,6 +45,7 @@ tests/test_inventario_i18n.py
45
45
  tests/test_inventory.py
46
46
  tests/test_inventory_new.py
47
47
  tests/test_kpi.py
48
+ tests/test_kpi_grafica.py
48
49
  tests/test_mg1.py
49
50
  tests/test_mmc_estable.py
50
51
  tests/test_mrp.py
@@ -55,6 +56,7 @@ tests/test_notas_release.py
55
56
  tests/test_operations.py
56
57
  tests/test_plotting.py
57
58
  tests/test_politicas_inventario.py
59
+ tests/test_propiedades_diferenciales.py
58
60
  tests/test_queuing.py
59
61
  tests/test_readme.py
60
62
  tests/test_referencias_externas.py
@@ -68,4 +70,5 @@ tests/test_scorecard.py
68
70
  tests/test_solver.py
69
71
  tests/test_trazabilidad.py
70
72
  tests/test_version.py
73
+ tests/test_wagner_whitin_ceros.py
71
74
  tests/test_weibull.py
@@ -77,10 +77,18 @@ def test_neh_n200_m10_es_rapido():
77
77
 
78
78
 
79
79
  def _ww_fuerza_bruta(d, S, h):
80
+ """Óptimo por enumeración, independiente de la programación dinámica.
81
+
82
+ Un pedido solo se coloca en un periodo con demanda positiva (no hay nada que cubrir en uno nulo) y el primero
83
+ de ellos debe tener pedido. Un horizonte sin demanda cuesta 0.
84
+ """
85
+ positivos = [t for t, x in enumerate(d) if x > 0]
86
+ if not positivos:
87
+ return 0.0
80
88
  n = len(d)
81
89
  mejor = math.inf
82
- for mascara in range(1 << (n - 1)):
83
- inicios = [0] + [i + 1 for i in range(n - 1) if mascara >> i & 1]
90
+ for mascara in range(1 << (len(positivos) - 1)):
91
+ inicios = [positivos[0]] + [positivos[i + 1] for i in range(len(positivos) - 1) if mascara >> i & 1]
84
92
  costo = 0.0
85
93
  for si, a in enumerate(inicios):
86
94
  fin = inicios[si + 1] if si + 1 < len(inicios) else n
@@ -93,8 +101,7 @@ def _ww_fuerza_bruta(d, S, h):
93
101
  def test_wagner_whitin_coincide_con_fuerza_bruta(semilla):
94
102
  r = random.Random(semilla)
95
103
  n = r.randint(2, 9)
96
- d = [r.randint(0, 40) for _ in range(n)]
97
- d[0] = d[0] or 1
104
+ d = [r.choice([0, 0, r.randint(1, 40)]) for _ in range(n)] # ceros en cualquier posición (W-27)
98
105
  S, h = r.randint(10, 100), r.choice([0.5, 1, 2])
99
106
  assert wl.wagner_whitin(d, S, h).total_cost == pytest.approx(_ww_fuerza_bruta(d, S, h), abs=1e-9)
100
107
 
@@ -0,0 +1,82 @@
1
+ """Los árboles de KPI se dibujan (W-26).
2
+
3
+ Con ``branchvalues="total"`` Plotly exige que el valor de cada padre sea **al menos** la suma de los de sus hijos; si no, la figura sale en blanco en el
4
+ navegador. Un KPI casi nunca es la suma de sus hijos (EBITDA = Ingresos − Costos; OEE = A × P × Q), así que ``plot_kpi_tree`` usa como tamaño del
5
+ rectángulo ``max(valor, suma de los tamaños de los hijos)`` y rotula el valor real del nodo. Estos tests comprueban esa condición sobre la figura
6
+ (el objeto ``Figure`` no se renderiza aquí).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ import walopy as wl
13
+
14
+ pytest.importorskip("plotly")
15
+
16
+
17
+ def _ebitda() -> wl.KPINode:
18
+ a = wl.KPINode("Producto A", value=120_000, unit="€")
19
+ b = wl.KPINode("Producto B", value=80_000, unit="€")
20
+ ingresos = wl.KPINode("Ingresos", value=200_000, unit="€", children=[a, b])
21
+ costos = wl.KPINode("Costos de operación", value=100_000, unit="€")
22
+ return wl.KPINode("EBITDA", value=100_000, unit="€", children=[ingresos, costos])
23
+
24
+
25
+ def _suma_exacta() -> wl.KPINode:
26
+ return wl.KPINode("Total", value=300.0, children=[wl.KPINode("A", value=100.0), wl.KPINode("B", value=200.0)])
27
+
28
+
29
+ ARBOLES = {
30
+ "ebitda": _ebitda,
31
+ "oee": lambda: wl.oee_kpi_tree(0.9, 0.8, 0.95),
32
+ "throughput": lambda: wl.throughput_kpi_tree(750.0, 1000.0, 0.05),
33
+ "roi": lambda: wl.roi_kpi_tree(50_000.0, 10_000.0, 8.0, 2_000.0, 20_000.0),
34
+ "suma_exacta": _suma_exacta,
35
+ "una_hoja": lambda: wl.KPINode("Solo", value=5.0),
36
+ "con_cero": lambda: wl.KPINode("R", value=0.0, children=[wl.KPINode("H", value=0.0)]),
37
+ "negativo": lambda: wl.KPINode("R", value=-10.0, children=[wl.KPINode("H1", value=-30.0), wl.KPINode("H2", value=5.0)]),
38
+ }
39
+
40
+
41
+ def _violaciones(traza) -> list[str]:
42
+ """Padres cuyo valor es menor que la suma de sus hijos (lo que deja a Plotly con la figura en blanco)."""
43
+ valor = dict(zip(traza.ids, traza.values))
44
+ suma: dict[str, float] = {}
45
+ for id_, padre in zip(traza.ids, traza.parents):
46
+ if padre:
47
+ suma[padre] = suma.get(padre, 0.0) + valor[id_]
48
+ return [f"{p}: {valor[p]:g} < {s:g}" for p, s in suma.items() if valor[p] < s * (1 - 1e-12)]
49
+
50
+
51
+ @pytest.mark.parametrize("kind", ["treemap", "sunburst"])
52
+ @pytest.mark.parametrize("nombre", sorted(ARBOLES))
53
+ def test_ningun_padre_vale_menos_que_la_suma_de_sus_hijos(nombre, kind):
54
+ fig = ARBOLES[nombre]().plot(kind=kind)
55
+ traza = fig.data[0]
56
+ assert traza.branchvalues == "total"
57
+ assert not _violaciones(traza), f"{nombre} ({kind}) se dibujaría en blanco: {_violaciones(traza)}"
58
+ assert all(v > 0 for v in traza.values)
59
+
60
+
61
+ @pytest.mark.parametrize("kind", ["treemap", "sunburst"])
62
+ def test_el_rotulo_es_el_valor_real_del_nodo_no_el_tamano(kind):
63
+ fig = _ebitda().plot(kind=kind)
64
+ traza = fig.data[0]
65
+ rotulos = dict(zip(traza.labels, traza.text))
66
+ assert rotulos["EBITDA"] == "100,000" # no 300 000, que es el tamaño del rectángulo
67
+ assert rotulos["Ingresos"] == "200,000" and rotulos["Producto A"] == "120,000"
68
+ assert "%{text}" in traza.texttemplate
69
+
70
+
71
+ def test_un_arbol_valido_conserva_los_tamanos_de_siempre():
72
+ """Si el padre ya es la suma (o mayor), nada cambia: tamaño = valor del nodo."""
73
+ traza = _suma_exacta().plot().data[0]
74
+ assert dict(zip(traza.labels, traza.values)) == {"Total": 300.0, "A": 100.0, "B": 200.0}
75
+ mayor = wl.KPINode("P", value=500.0, children=[wl.KPINode("H", value=100.0)])
76
+ assert dict(zip(mayor.plot().data[0].labels, mayor.plot().data[0].values)) == {"P": 500.0, "H": 100.0}
77
+
78
+
79
+ def test_el_texto_emergente_sigue_mostrando_el_valor_y_la_formula():
80
+ raiz = wl.KPINode("EBITDA", value=100_000, unit="€", formula="Ingresos − Costos", children=[wl.KPINode("X", value=1.0)])
81
+ hover = raiz.plot().data[0].customdata[0]
82
+ assert "100000" in hover and "Ingresos − Costos" in hover and "€" in hover
@@ -0,0 +1,118 @@
1
+ """Relaciones que deben cumplir funciones distintas del mismo modelo, sobre escenarios aleatorios con semilla fija.
2
+
3
+ Complementan a los tests de valores concretos: detectan errores que un caso a mano no cubre (como W-27). Carga moderada
4
+ (``N`` escenarios por familia); la semilla fija hace que un fallo sea reproducible.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import random
10
+
11
+ import walopy as wl
12
+
13
+ N = 300
14
+ SEMILLA = 20261007
15
+
16
+
17
+ def _cerca(a: float, b: float, rel: float = 1e-7) -> bool:
18
+ return math.isclose(a, b, rel_tol=rel, abs_tol=1e-12)
19
+
20
+
21
+ def _costo_eoq(D: float, K: float, h: float, q: float) -> float:
22
+ return D / q * K + h * q / 2
23
+
24
+
25
+ def _escenarios(familia: str):
26
+ r = random.Random(f"{SEMILLA}-{familia}")
27
+ return [random.Random(r.random()) for _ in range(N)]
28
+
29
+
30
+ def _cola(r):
31
+ mu = r.uniform(0.2, 50)
32
+ lam = r.uniform(0.01, 0.97) * mu
33
+ return lam, mu, wl.mm1(lam, mu)
34
+
35
+
36
+ def test_mg1_con_cs2_igual_a_1_es_mm1():
37
+ for r in _escenarios("mg1"):
38
+ lam, mu, m = _cola(r)
39
+ g = wl.mg1(lam, mu, 1.0)
40
+ assert _cerca(g.L, m.L) and _cerca(g.Wq, m.Wq), (lam, mu)
41
+
42
+
43
+ def test_kingman_con_cv_1_coincide_con_mm1():
44
+ for r in _escenarios("kingman"):
45
+ lam, mu, m = _cola(r)
46
+ assert _cerca(wl.kingman(lam, mu, 1.0, 1.0).Wq, m.Wq), (lam, mu)
47
+
48
+
49
+ def test_md1_tiene_la_mitad_de_cola_que_mm1():
50
+ for r in _escenarios("md1"):
51
+ lam, mu, m = _cola(r)
52
+ assert _cerca(wl.md1(lam, mu).Lq, m.Lq / 2), (lam, mu)
53
+
54
+
55
+ def test_mmc_con_un_servidor_es_mm1():
56
+ for r in _escenarios("mmc1"):
57
+ lam, mu, m = _cola(r)
58
+ c1 = wl.mmc(lam, mu, 1)
59
+ assert _cerca(c1.Wq, m.Wq) and _cerca(c1.L, m.L), (lam, mu)
60
+
61
+
62
+ def test_erlang_b_decrece_al_añadir_servidores():
63
+ for r in _escenarios("erlang"):
64
+ c, a = r.randint(1, 40), r.uniform(0.1, 60)
65
+ b0, b1 = wl.erlang_b(a, 1.0, c), wl.erlang_b(a, 1.0, c + 1)
66
+ assert 0 <= b1 <= b0 + 1e-15 and b0 <= 1, (a, c, b0, b1)
67
+
68
+
69
+ def test_mmc_la_espera_decrece_al_añadir_un_servidor():
70
+ for r in _escenarios("mmc_c"):
71
+ c, mu = r.randint(1, 15), r.uniform(1, 20)
72
+ lam = r.uniform(0.05, 0.95) * c * mu
73
+ w1, w2 = wl.mmc(lam, mu, c).Wq, wl.mmc(lam, mu, c + 1).Wq
74
+ assert w2 <= w1 + 1e-12, (lam, mu, c, w1, w2)
75
+
76
+
77
+ def test_lotes_wagner_whitin_no_supera_a_las_heuristicas():
78
+ for r in _escenarios("lotes"):
79
+ d = [r.choice([0, 0, r.uniform(1, 200)]) for _ in range(r.randint(2, 12))]
80
+ if sum(d) == 0:
81
+ continue
82
+ S, h = r.uniform(1, 500), r.uniform(0.05, 5)
83
+ ww = wl.wagner_whitin(d, S, h).total_cost
84
+ assert ww <= wl.silver_meal(d, S, h).total_cost + 1e-6, (d, S, h)
85
+ assert ww <= wl.lot_for_lot(d, S, h).total_cost + 1e-6, (d, S, h)
86
+
87
+
88
+ def test_fiabilidad_serie_y_paralelo_acotan_a_los_componentes():
89
+ for r in _escenarios("fiabilidad"):
90
+ n = r.randint(2, 6)
91
+ lams = [r.uniform(1e-3, 0.2) for _ in range(n)]
92
+ t = r.uniform(0.5, 100)
93
+ Ri = [math.exp(-x * t) for x in lams]
94
+ assert wl.series_system(lams, t=t).R_t <= min(Ri) + 1e-12, (lams, t)
95
+ assert wl.parallel_system(lams, t=t).R_t >= max(Ri) - 1e-12, (lams, t)
96
+
97
+
98
+ def test_k_de_n_esta_entre_serie_y_paralelo():
99
+ for r in _escenarios("koon"):
100
+ n = r.randint(2, 6)
101
+ k, lam, t = r.randint(1, n), r.uniform(1e-3, 0.2), r.uniform(0.5, 100)
102
+ rk = wl.koon_system(n=n, k=k, failure_rate=lam, t=t).R_t
103
+ assert math.exp(-lam * t) ** n - 1e-12 <= rk <= 1 - (1 - math.exp(-lam * t)) ** n + 1e-12, (n, k, lam, t)
104
+
105
+
106
+ def test_eoq_es_el_minimo_del_costo_y_coincide_con_la_formula():
107
+ for r in _escenarios("eoq"):
108
+ D, K, h = r.uniform(1, 5000), r.uniform(1, 500), r.uniform(0.01, 10)
109
+ e = wl.eoq(D, K, h)
110
+
111
+ assert _cerca(e.total_cost, _costo_eoq(D, K, h, e.eoq), 1e-9), (D, K, h)
112
+ assert all(e.total_cost <= _costo_eoq(D, K, h, e.eoq * f) + 1e-9 for f in (0.5, 0.9, 1.1, 2)), (D, K, h)
113
+
114
+
115
+ def test_eoq_decrece_con_el_costo_de_mantener():
116
+ for r in _escenarios("eoq_h"):
117
+ D, K, h = r.uniform(1, 5000), r.uniform(1, 500), r.uniform(0.01, 10)
118
+ assert wl.eoq(D, K, h * 1.5).eoq < wl.eoq(D, K, h).eoq, (D, K, h)
@@ -3,6 +3,9 @@
3
3
  ``tests/golden/salida_es.json`` se generó con ``python scripts/instantanea_salida.py --escribir`` sobre el código de v0.3.0, ANTES de
4
4
  mover los textos a un catálogo. Cubre resultados, tablas, mensajes de error, avisos, gráficas, README y CLI, con el idioma por defecto.
5
5
  Si cambia a propósito un texto en español, regenera la instantánea y revisa el diff como parte del PR.
6
+
7
+ Excepción deliberada (W-26): la ``grafica`` de ``oee_kpi_tree``, ``throughput_kpi_tree`` y ``roi_kpi_tree`` se actualizó a mano en la instantánea: ahora
8
+ rotula el valor real de cada nodo en ``text`` (los árboles salían en blanco en el navegador, ver ``tests/test_kpi_grafica.py``).
6
9
  """
7
10
  from __future__ import annotations
8
11
 
@@ -0,0 +1,61 @@
1
+ """W-27: ``wagner_whitin`` cobraba preparación en periodos sin demanda y obligaba a pedir en el periodo 1."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import random
6
+
7
+ import pytest
8
+
9
+ import walopy as wl
10
+ from tests.test_complejidad import _ww_fuerza_bruta
11
+
12
+
13
+ def _pedidos(r):
14
+ return [(o["period"], o["order_qty"]) for o in r.orders]
15
+
16
+
17
+ def test_ceros_iniciales_no_obligan_a_pedir_en_el_periodo_1():
18
+ r = wl.wagner_whitin([0, 0, 100, 0, 0], setup_cost=100, holding_cost=1)
19
+ assert r.total_cost == pytest.approx(100.0) # antes 200: pedido vacío en el periodo 1 + el real
20
+ assert _pedidos(r) == [(3, 100.0)]
21
+
22
+
23
+ def test_ceros_intermedios_no_pagan_preparacion():
24
+ r = wl.wagner_whitin([0, 50, 0, 100], setup_cost=100, holding_cost=1)
25
+ assert r.total_cost == pytest.approx(200.0) # antes 250
26
+ assert _pedidos(r) == [(2, 50.0), (4, 100.0)]
27
+
28
+
29
+ def test_horizonte_sin_demanda_cuesta_cero_y_no_pide():
30
+ r = wl.wagner_whitin([0, 0, 0], setup_cost=100, holding_cost=1)
31
+ assert r.total_cost == 0.0
32
+ assert r.orders == [] and r.n_orders == 0
33
+ assert r.total_setup_cost == 0.0 and r.total_holding_cost == 0.0
34
+
35
+
36
+ def test_ningun_pedido_es_de_cantidad_cero():
37
+ r = random.Random(27)
38
+ for _ in range(200):
39
+ d = [r.choice([0, 0, r.randint(1, 40)]) for _ in range(r.randint(1, 12))]
40
+ res = wl.wagner_whitin(d, r.randint(5, 80), r.choice([0.5, 1, 2]))
41
+ assert all(o["order_qty"] > 0 for o in res.orders)
42
+ assert sum(o["order_qty"] for o in res.orders) == pytest.approx(sum(d))
43
+
44
+
45
+ @pytest.mark.parametrize("semilla", range(150))
46
+ def test_costo_optimo_exacto_con_ceros_en_cualquier_posicion(semilla):
47
+ r = random.Random(7000 + semilla)
48
+ d = [r.choice([0, 0, r.randint(1, 60)]) for _ in range(r.randint(1, 10))]
49
+ S, h = r.uniform(1, 200), r.uniform(0.05, 3)
50
+ res = wl.wagner_whitin(d, S, h)
51
+ assert res.total_cost == pytest.approx(_ww_fuerza_bruta(d, S, h), rel=1e-9, abs=1e-9)
52
+ assert res.total_cost == pytest.approx(res.total_setup_cost + res.total_holding_cost)
53
+ assert res.total_setup_cost == pytest.approx(res.n_orders * S)
54
+
55
+
56
+ def test_con_demanda_positiva_el_resultado_no_cambia():
57
+ # Caso de referencia del docstring: sin ceros el plan es el de siempre.
58
+ r = wl.wagner_whitin([100, 80, 120, 60], setup_cost=200, holding_cost=1)
59
+ assert r.total_cost == pytest.approx(_ww_fuerza_bruta([100, 80, 120, 60], 200, 1))
60
+ assert r.orders[0]["period"] == 1
61
+ assert math.isfinite(r.total_cost)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes