beefcot 1.0.4__tar.gz → 1.1.0__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.
- {beefcot-1.0.4 → beefcot-1.1.0}/PKG-INFO +1 -1
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/__init__.py +1 -1
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/figures.py +1 -1
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/economics.py +161 -37
- beefcot-1.1.0/src/beefcot/cutting_plan/eligibility.py +158 -0
- beefcot-1.1.0/src/beefcot/cutting_plan/receipt.py +123 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/run.py +153 -25
- beefcot-1.1.0/src/beefcot/cutting_plan/solver.py +179 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/app.py +295 -27
- beefcot-1.1.0/src/beefcot/service/errors.py +19 -0
- beefcot-1.1.0/src/beefcot/service/history.py +250 -0
- beefcot-1.1.0/src/beefcot/service/job_process.py +90 -0
- beefcot-1.1.0/src/beefcot/service/model_evidence.py +26 -0
- beefcot-1.1.0/src/beefcot/service/plan_analysis.py +246 -0
- beefcot-1.1.0/src/beefcot/service/plan_comparison.py +133 -0
- beefcot-1.1.0/src/beefcot/service/plan_editor.py +237 -0
- beefcot-1.1.0/src/beefcot/service/plan_evidence.py +162 -0
- beefcot-1.1.0/src/beefcot/service/plan_export.py +122 -0
- beefcot-1.1.0/src/beefcot/service/plan_figures.py +41 -0
- beefcot-1.1.0/src/beefcot/service/plan_inputs.py +313 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/plans.py +72 -22
- beefcot-1.1.0/src/beefcot/service/purchase_ceiling.py +73 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/runner.py +20 -19
- beefcot-1.1.0/src/beefcot/service/saved_plan.py +203 -0
- beefcot-1.1.0/src/beefcot/service/views.py +509 -0
- beefcot-1.0.4/src/beefcot/cutting_plan/solver.py +0 -104
- beefcot-1.0.4/src/beefcot/service/errors.py +0 -11
- {beefcot-1.0.4 → beefcot-1.1.0}/.gitignore +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/README.pypi.md +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/pyproject.toml +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/__main__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/anomaly.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/chain.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/anomaly.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/anomaly_figures.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/bins.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/cut_tree.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/forest.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/report.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/review.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/spec.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/table.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cli.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cli_presentation.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/analysis.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/fits.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/report.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/sources.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/spec.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/analysis.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/methods.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/plots.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/report.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/sources.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/spec.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/expression.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/functional.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/learner_registry.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/learners.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/rows.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/runtime.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/topology.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/transforms.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/types.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/blocks.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/card.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/documents.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/export.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/fitted.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/library.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/schema.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/prediction/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/prediction/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/network.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/loop.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/relevance.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/project.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/project_registry.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/registry.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/anomaly.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/assembly.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/assembly_network.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/candidates.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive_parallel.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive_storage.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/factory.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/mapping_review.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/mapping_space.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/provenance.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_checkpoint.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_controller.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_effort.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_equivalence.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_policy.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_selection.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_settings.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_warm_start.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/residual.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rf_expression.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/spec.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/table.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/metrics.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/plots.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/regression_space.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/run.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/style.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/resources/cut_tree.default.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/resources/rerf_space.default.yaml +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/expression_ast.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/loader.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/model.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/checks-3.schema.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/model-3.schema.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/predictors-3.schema.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/request-3.schema.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/result-3.schema.json +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/__init__.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/auth.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/projects.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/server.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/studies.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/workspace.py +0 -0
- {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/yamlio.py +0 -0
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
from typing import Any
|
|
26
26
|
|
|
27
|
-
__version__ = "1.0
|
|
27
|
+
__version__ = "1.1.0" # the one place it is written; CHANGELOG.md records each version
|
|
28
28
|
|
|
29
29
|
__all__ = ["__version__", "fit", "predict", "plan", "check", "decompose", "run", "load_model"]
|
|
30
30
|
|
|
@@ -79,7 +79,7 @@ def plot_config(
|
|
|
79
79
|
"DataSet": [{"name": "animals", "path": "../animals.csv", "type": "csv"},
|
|
80
80
|
{"name": "cells", "path": "../cells.csv", "type": "csv"}],
|
|
81
81
|
"Figures": figures,
|
|
82
|
-
"output": {"dir": ".", "formats": ["png"]},
|
|
82
|
+
"output": {"dir": ".", "formats": ["svg", "png"]},
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
|
|
@@ -6,13 +6,14 @@ built from three things, and only the first is ours:
|
|
|
6
6
|
|
|
7
7
|
product yield what each carcass gives for each product <- Model.predict_table
|
|
8
8
|
prices what each product sells for <- scenario
|
|
9
|
-
pattern costs
|
|
9
|
+
pattern costs labor rate, processing speed and packaging <- scenario
|
|
10
10
|
|
|
11
11
|
This module builds those matrices and stops there. The mixed-integer solve is a separate
|
|
12
12
|
concern: ``solve`` hands the matrices to HiGHS (``plan/solver.py``).
|
|
13
13
|
|
|
14
14
|
Units, as the reference optimizer defines them: ``process_speed`` is carcasses per **minute**
|
|
15
|
-
and ``hourly_labor_cost`` is money per hour, so
|
|
15
|
+
and ``hourly_labor_cost`` is money per hour, so labor per carcass is hourly cost / speed / 60.
|
|
16
|
+
The optional ``packaging_cost_per_head`` is already a per-carcass cost.
|
|
16
17
|
|
|
17
18
|
Coverage is explicit. The delivered tree predicts the 16 measured cuts (and their totals);
|
|
18
19
|
a pattern may name products it does not cover, and pretending otherwise is how a plan ends
|
|
@@ -31,16 +32,40 @@ enough of it for the level to mean something.
|
|
|
31
32
|
from __future__ import annotations
|
|
32
33
|
|
|
33
34
|
from dataclasses import dataclass, field
|
|
35
|
+
import math
|
|
34
36
|
from typing import Any, Mapping, Sequence
|
|
35
37
|
|
|
36
38
|
import numpy as np
|
|
37
39
|
import pandas as pd
|
|
38
40
|
|
|
39
41
|
from beefcot.runtime import Model
|
|
42
|
+
from beefcot.cutting_plan.eligibility import Eligibility, Rule, evaluate, parse_rules
|
|
40
43
|
|
|
41
44
|
UNCOVERED_POLICIES = ("error", "zero", "external")
|
|
42
45
|
# Does every carcass have to be processed, or may a carcass be left out of the plan?
|
|
43
46
|
ASSIGNMENT_RULES = ("exactly_one", "at_most_one")
|
|
47
|
+
PURCHASE_PRICE_BASES = ("per_head", "per_kg")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def amount(value: Any, context: str, *, positive: bool = False) -> float:
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
raise ValueError(f"{context}: expected a finite number")
|
|
53
|
+
try:
|
|
54
|
+
number = float(value)
|
|
55
|
+
except (TypeError, ValueError) as failure:
|
|
56
|
+
raise ValueError(f"{context}: expected a finite number") from failure
|
|
57
|
+
if not math.isfinite(number) or (number <= 0 if positive else number < 0):
|
|
58
|
+
raise ValueError(f"{context}: must be finite and {'positive' if positive else 'nonnegative'}")
|
|
59
|
+
return number
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def whole(value: Any, context: str) -> int | None:
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
number = amount(value, context)
|
|
66
|
+
if number != math.floor(number) or number > 2**53 - 1:
|
|
67
|
+
raise ValueError(f"{context}: must be an exactly representable whole number")
|
|
68
|
+
return int(number)
|
|
44
69
|
|
|
45
70
|
|
|
46
71
|
@dataclass(frozen=True)
|
|
@@ -57,6 +82,21 @@ class Pattern:
|
|
|
57
82
|
# scenario's list. A slower, closer trim sells the same cut for more; a fast break sells
|
|
58
83
|
# it for less. Products left out here are sold at the list price.
|
|
59
84
|
prices: Mapping[str, float] = field(default_factory=dict)
|
|
85
|
+
eligibility: tuple[Rule, ...] = ()
|
|
86
|
+
packaging_cost_per_head: float = 0.0
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if not self.name.strip() or not self.products:
|
|
90
|
+
raise ValueError(f"pattern {self.name!r}: name and products must be nonempty")
|
|
91
|
+
amount(self.process_speed, f"pattern {self.name}: process_speed", positive=True)
|
|
92
|
+
amount(self.hourly_labor_cost, f"pattern {self.name}: hourly_labor_cost")
|
|
93
|
+
amount(self.packaging_cost_per_head, f"pattern {self.name}: packaging_cost_per_head")
|
|
94
|
+
for key in ("min_carcasses", "max_carcasses"):
|
|
95
|
+
whole(getattr(self, key), f"pattern {self.name}: {key}")
|
|
96
|
+
if self.min_carcasses is not None and self.max_carcasses is not None and self.min_carcasses > self.max_carcasses:
|
|
97
|
+
raise ValueError(f"pattern {self.name}: min_carcasses exceeds max_carcasses")
|
|
98
|
+
for product, price in self.prices.items():
|
|
99
|
+
amount(price, f"pattern {self.name}: prices.{product}")
|
|
60
100
|
|
|
61
101
|
@property
|
|
62
102
|
def labor_cost_per_carcass(self) -> float:
|
|
@@ -70,7 +110,7 @@ class Pattern:
|
|
|
70
110
|
if not isinstance(payload, Mapping):
|
|
71
111
|
raise TypeError("a pattern must be a mapping")
|
|
72
112
|
allowed = {"name", "products", "process_speed", "hourly_labor_cost", "min_carcasses",
|
|
73
|
-
"max_carcasses", "prices"}
|
|
113
|
+
"max_carcasses", "prices", "eligibility", "packaging_cost_per_head"}
|
|
74
114
|
unknown = sorted(set(payload) - allowed)
|
|
75
115
|
if unknown:
|
|
76
116
|
raise ValueError(f"pattern {payload.get('name')!r}: unknown keys={unknown}; allowed={sorted(allowed)}")
|
|
@@ -78,14 +118,19 @@ class Pattern:
|
|
|
78
118
|
missing = [key for key in required if key not in payload]
|
|
79
119
|
if missing:
|
|
80
120
|
raise ValueError(f"pattern {payload.get('name', '?')!r}: missing keys={missing}")
|
|
121
|
+
if not isinstance(payload["products"], (list, tuple)):
|
|
122
|
+
raise ValueError(f"pattern {payload['name']}: products must be a list")
|
|
81
123
|
return cls(
|
|
82
124
|
name=str(payload["name"]),
|
|
83
125
|
products=tuple(str(item) for item in payload["products"]),
|
|
84
|
-
process_speed=
|
|
85
|
-
hourly_labor_cost=
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
126
|
+
process_speed=amount(payload["process_speed"], f"pattern {payload['name']}: process_speed", positive=True),
|
|
127
|
+
hourly_labor_cost=amount(payload["hourly_labor_cost"], f"pattern {payload['name']}: hourly_labor_cost"),
|
|
128
|
+
packaging_cost_per_head=amount(payload.get("packaging_cost_per_head", 0),
|
|
129
|
+
f"pattern {payload['name']}: packaging_cost_per_head"),
|
|
130
|
+
min_carcasses=whole(payload.get("min_carcasses"), f"pattern {payload['name']}: min_carcasses"),
|
|
131
|
+
max_carcasses=whole(payload.get("max_carcasses"), f"pattern {payload['name']}: max_carcasses"),
|
|
132
|
+
eligibility=parse_rules(payload.get("eligibility", []), f"pattern {payload['name']} eligibility"),
|
|
133
|
+
prices={str(key): amount(value, f"pattern {payload['name']}: prices.{key}") for key, value in dict(payload.get("prices") or {}).items()},
|
|
89
134
|
)
|
|
90
135
|
|
|
91
136
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -94,9 +139,11 @@ class Pattern:
|
|
|
94
139
|
"products": list(self.products),
|
|
95
140
|
"process_speed": self.process_speed,
|
|
96
141
|
"hourly_labor_cost": self.hourly_labor_cost,
|
|
142
|
+
**({"packaging_cost_per_head": self.packaging_cost_per_head} if self.packaging_cost_per_head else {}),
|
|
97
143
|
"min_carcasses": self.min_carcasses,
|
|
98
144
|
"max_carcasses": self.max_carcasses,
|
|
99
145
|
"prices": dict(self.prices),
|
|
146
|
+
**({"eligibility": [rule.to_dict() for rule in self.eligibility]} if self.eligibility else {}),
|
|
100
147
|
}
|
|
101
148
|
|
|
102
149
|
|
|
@@ -109,14 +156,50 @@ class ScenarioSpec:
|
|
|
109
156
|
pieces_per_carcass: float = 2.0 # both sides; the tree predicts one side
|
|
110
157
|
purchase_price_column: str | None = "grid_price"
|
|
111
158
|
weight_column: str | None = None # the carcass weight the purchase price is per kg of
|
|
112
|
-
purchase_price: float | None = None #
|
|
159
|
+
purchase_price: float | None = None # scalar fallback when the source column is absent
|
|
113
160
|
available_column: str | None = "num_available"
|
|
114
161
|
uncovered_products: str = "error"
|
|
115
162
|
assignment: str = "exactly_one" # exactly_one | at_most_one
|
|
163
|
+
units: Mapping[str, str | None] = field(default_factory=dict)
|
|
164
|
+
purchase_price_basis: str = "per_kg"
|
|
165
|
+
purchase_price_column_basis: str = "per_kg"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def yield_factor(self) -> float:
|
|
169
|
+
return 1.0 if self.units.get("yield") == "kg/carcass" else self.pieces_per_carcass
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def purchase_weight_factor(self) -> float:
|
|
173
|
+
return 1.0 if self.units.get("purchase_weight") == "kg/carcass" else self.pieces_per_carcass
|
|
174
|
+
|
|
175
|
+
def purchase_input(self, columns) -> tuple[str | None, str | None]:
|
|
176
|
+
if self.purchase_price_column and self.purchase_price_column in columns:
|
|
177
|
+
return self.purchase_price_column, self.purchase_price_column_basis
|
|
178
|
+
if self.purchase_price is not None:
|
|
179
|
+
return None, self.purchase_price_basis
|
|
180
|
+
return None, None
|
|
116
181
|
|
|
117
182
|
def __post_init__(self) -> None:
|
|
183
|
+
for key in ("purchase_price_basis", "purchase_price_column_basis"):
|
|
184
|
+
if getattr(self, key) not in PURCHASE_PRICE_BASES:
|
|
185
|
+
raise ValueError(f"scenario.{key}: expected per_head or per_kg")
|
|
118
186
|
if not self.patterns:
|
|
119
187
|
raise ValueError("a scenario needs at least one pattern")
|
|
188
|
+
if len({pattern.name for pattern in self.patterns}) != len(self.patterns):
|
|
189
|
+
raise ValueError("scenario: pattern names must be unique")
|
|
190
|
+
amount(self.pieces_per_carcass, "scenario.pieces_per_carcass", positive=True)
|
|
191
|
+
for product, price in self.prices.items():
|
|
192
|
+
amount(price, f"scenario.prices.{product}")
|
|
193
|
+
if self.purchase_price is not None:
|
|
194
|
+
amount(self.purchase_price, "scenario.purchase_price")
|
|
195
|
+
if not isinstance(self.units, Mapping) or set(self.units) - {"yield", "purchase_weight", "currency"}:
|
|
196
|
+
raise ValueError("scenario.units: allowed keys are yield, purchase_weight, currency")
|
|
197
|
+
for key in ("yield", "purchase_weight"):
|
|
198
|
+
if self.units.get(key, "kg/side") not in {"kg/side", "kg/carcass"}:
|
|
199
|
+
raise ValueError(f"scenario.units.{key}: expected kg/side or kg/carcass")
|
|
200
|
+
currency = self.units.get("currency")
|
|
201
|
+
if currency is not None and (not isinstance(currency, str) or len(currency) != 3 or not currency.isascii() or not currency.isupper() or not currency.isalpha()):
|
|
202
|
+
raise ValueError("scenario.units.currency: expected a three-letter uppercase currency code")
|
|
120
203
|
if self.assignment not in ASSIGNMENT_RULES:
|
|
121
204
|
raise ValueError(f"assignment={self.assignment!r}; allowed={list(ASSIGNMENT_RULES)}")
|
|
122
205
|
if self.uncovered_products not in UNCOVERED_POLICIES:
|
|
@@ -140,6 +223,9 @@ class ScenarioSpec:
|
|
|
140
223
|
for pattern in self.patterns:
|
|
141
224
|
for product in pattern.products:
|
|
142
225
|
seen.setdefault(product, None)
|
|
226
|
+
for rule in pattern.eligibility:
|
|
227
|
+
if rule.kind == "product":
|
|
228
|
+
seen.setdefault(rule.name, None)
|
|
143
229
|
return tuple(seen)
|
|
144
230
|
|
|
145
231
|
@classmethod
|
|
@@ -149,6 +235,7 @@ class ScenarioSpec:
|
|
|
149
235
|
allowed = {
|
|
150
236
|
"patterns", "prices", "pieces_per_carcass", "purchase_price_column", "purchase_price",
|
|
151
237
|
"weight_column", "available_column", "uncovered_products", "assignment",
|
|
238
|
+
"units", "purchase_price_basis", "purchase_price_column_basis",
|
|
152
239
|
}
|
|
153
240
|
unknown = sorted(set(payload) - allowed)
|
|
154
241
|
if unknown:
|
|
@@ -158,14 +245,17 @@ class ScenarioSpec:
|
|
|
158
245
|
raise ValueError(f"scenario: missing keys={missing}")
|
|
159
246
|
return cls(
|
|
160
247
|
patterns=tuple(Pattern.from_dict(item) for item in payload["patterns"]),
|
|
161
|
-
prices={str(key):
|
|
162
|
-
pieces_per_carcass=
|
|
248
|
+
prices={str(key): amount(value, f"scenario.prices.{key}") for key, value in dict(payload["prices"]).items()},
|
|
249
|
+
pieces_per_carcass=amount(payload.get("pieces_per_carcass", 2.0), "scenario.pieces_per_carcass", positive=True),
|
|
163
250
|
purchase_price_column=payload.get("purchase_price_column", "grid_price"),
|
|
164
251
|
weight_column=None if payload.get("weight_column") is None else str(payload["weight_column"]),
|
|
165
|
-
purchase_price=None if payload.get("purchase_price") is None else
|
|
252
|
+
purchase_price=None if payload.get("purchase_price") is None else amount(payload["purchase_price"], "scenario.purchase_price"),
|
|
166
253
|
available_column=payload.get("available_column", "num_available"),
|
|
167
254
|
uncovered_products=str(payload.get("uncovered_products", "error")),
|
|
168
255
|
assignment=str(payload.get("assignment", "exactly_one")),
|
|
256
|
+
units=payload.get("units", {}),
|
|
257
|
+
purchase_price_basis=payload.get("purchase_price_basis", "per_kg"),
|
|
258
|
+
purchase_price_column_basis=payload.get("purchase_price_column_basis", "per_kg"),
|
|
169
259
|
)
|
|
170
260
|
|
|
171
261
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -179,6 +269,9 @@ class ScenarioSpec:
|
|
|
179
269
|
"available_column": self.available_column,
|
|
180
270
|
"uncovered_products": self.uncovered_products,
|
|
181
271
|
"assignment": self.assignment,
|
|
272
|
+
"units": dict(self.units),
|
|
273
|
+
**{key: getattr(self, key) for key in ("purchase_price_basis", "purchase_price_column_basis")
|
|
274
|
+
if getattr(self, key) != "per_kg"},
|
|
182
275
|
}
|
|
183
276
|
|
|
184
277
|
|
|
@@ -200,6 +293,8 @@ class CuttingPlanInputs:
|
|
|
200
293
|
# pattern -> the measured cuts it sells once its totals are counted as their cuts; two
|
|
201
294
|
# patterns are a like-for-like choice only when these are the same
|
|
202
295
|
pattern_cuts: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
296
|
+
eligibility: Eligibility | None = None
|
|
297
|
+
animal_ids: tuple[str, ...] = ()
|
|
203
298
|
|
|
204
299
|
|
|
205
300
|
@dataclass
|
|
@@ -212,6 +307,12 @@ class PlanResult:
|
|
|
212
307
|
per_pattern_count: dict[str, int]
|
|
213
308
|
solver: str
|
|
214
309
|
seconds: float | None = None
|
|
310
|
+
outcome: str = "error"
|
|
311
|
+
bound: float | None = None # upper bound, in the maximisation problem's sign
|
|
312
|
+
gap: float | None = None
|
|
313
|
+
termination: str | None = None
|
|
314
|
+
verification: dict[str, Any] = field(default_factory=dict)
|
|
315
|
+
constraint_rows: list[dict[str, Any]] = field(default_factory=list)
|
|
215
316
|
|
|
216
317
|
|
|
217
318
|
# The one solver: HiGHS through SciPy (plan/solver.py). A plan YAML may name it, nothing else.
|
|
@@ -224,6 +325,8 @@ def build_inputs(
|
|
|
224
325
|
carcasses: pd.DataFrame,
|
|
225
326
|
*,
|
|
226
327
|
external_yield: pd.DataFrame | None = None,
|
|
328
|
+
eligibility_source: pd.DataFrame | None = None,
|
|
329
|
+
animal_ids: tuple[str, ...] = (),
|
|
227
330
|
) -> CuttingPlanInputs:
|
|
228
331
|
"""Predict the carcasses, then build the income, cost and profit matrices.
|
|
229
332
|
|
|
@@ -251,21 +354,27 @@ def build_inputs(
|
|
|
251
354
|
used.append(model)
|
|
252
355
|
warnings.append((model, predicted.attrs.get("warnings", {})))
|
|
253
356
|
for product in needed:
|
|
357
|
+
contract_units = {str(item['code']): item.get('unit') for item in model.contract['products']}
|
|
358
|
+
members = model.totals.get(product, (product,))
|
|
359
|
+
if any(contract_units.get(member) != "kg" for member in members):
|
|
360
|
+
raise ValueError(f"model {model.id}, product {product}: cutting plans require model output in kg")
|
|
254
361
|
coverage[product] = model.id
|
|
255
362
|
frames[product] = pd.Series(predicted[product].to_numpy(dtype=float), index=index)
|
|
256
363
|
for product in scenario.products:
|
|
257
364
|
if product in frames:
|
|
258
365
|
continue
|
|
259
366
|
if external_yield is not None and product in external_yield.columns:
|
|
367
|
+
if len(external_yield) != len(carcasses):
|
|
368
|
+
raise ValueError(f"external yield product {product}: expected {len(carcasses)} rows, got {len(external_yield)}")
|
|
260
369
|
coverage[product] = "external"
|
|
261
|
-
frames[product] = pd.Series(external_yield
|
|
370
|
+
frames[product] = pd.Series(_amounts(external_yield, product, what="external product yield"), index=index)
|
|
262
371
|
continue
|
|
263
372
|
uncovered.append(product)
|
|
264
|
-
if scenario.uncovered_products
|
|
373
|
+
if scenario.uncovered_products in {"error", "external"}:
|
|
265
374
|
continue
|
|
266
375
|
coverage[product] = "zero"
|
|
267
376
|
frames[product] = pd.Series(np.zeros(len(carcasses)), index=index)
|
|
268
|
-
if uncovered and scenario.uncovered_products
|
|
377
|
+
if uncovered and scenario.uncovered_products in {"error", "external"}:
|
|
269
378
|
raise ValueError(
|
|
270
379
|
"no model given predicts these products and no external yield was "
|
|
271
380
|
f"given: {sorted(uncovered)}. Either supply them (uncovered_products: external) "
|
|
@@ -278,39 +387,30 @@ def build_inputs(
|
|
|
278
387
|
pattern_cuts = {pattern.name: _cuts_of(pattern, members_of) for pattern in scenario.patterns}
|
|
279
388
|
|
|
280
389
|
product_yield = pd.DataFrame(frames, index=index)
|
|
390
|
+
for product in product_yield:
|
|
391
|
+
_amounts(product_yield, product, what="predicted product yield")
|
|
281
392
|
income = pd.DataFrame(index=index)
|
|
282
393
|
labor = pd.DataFrame(index=index)
|
|
283
394
|
for pattern in scenario.patterns:
|
|
284
395
|
money = pd.Series(np.zeros(len(carcasses)), index=index)
|
|
285
396
|
for product in pattern.products:
|
|
286
397
|
price = float(pattern.prices.get(product, scenario.prices.get(product, 0.0)))
|
|
287
|
-
money = money + product_yield[product] * price * scenario.
|
|
398
|
+
money = money + product_yield[product] * price * scenario.yield_factor
|
|
288
399
|
income[pattern.name] = money
|
|
289
400
|
labor[pattern.name] = pattern.labor_cost_per_carcass
|
|
290
|
-
|
|
291
|
-
# cannot take and would report without saying which row; so every row is checked here.
|
|
292
|
-
if scenario.purchase_price_column and scenario.purchase_price_column in carcasses.columns:
|
|
293
|
-
price = _amounts(carcasses, scenario.purchase_price_column, what="the purchase price per kg")
|
|
294
|
-
elif scenario.purchase_price is not None:
|
|
295
|
-
price = np.full(len(carcasses), float(scenario.purchase_price))
|
|
296
|
-
else:
|
|
297
|
-
price = None # nothing is paid for, so the weight is not needed either
|
|
298
|
-
if price is None:
|
|
299
|
-
purchase = pd.Series(np.zeros(len(carcasses)), index=index)
|
|
300
|
-
else:
|
|
301
|
-
if not scenario.weight_column:
|
|
302
|
-
raise ValueError("a purchase price needs scenario.weight_column: the carcass weight it is per kg of")
|
|
303
|
-
weight = _amounts(carcasses, scenario.weight_column, what="the side weight the purchase cost is priced on")
|
|
304
|
-
purchase = pd.Series(weight * price * scenario.pieces_per_carcass, index=index)
|
|
401
|
+
purchase = pd.Series(purchase_costs(carcasses, scenario), index=index)
|
|
305
402
|
profit = income.subtract(labor, axis=0).subtract(purchase, axis=0)
|
|
403
|
+
for pattern in scenario.patterns:
|
|
404
|
+
if pattern.packaging_cost_per_head:
|
|
405
|
+
profit[pattern.name] -= float(pattern.packaging_cost_per_head)
|
|
306
406
|
if scenario.available_column and scenario.available_column in carcasses.columns:
|
|
307
407
|
raw = carcasses[scenario.available_column]
|
|
308
408
|
counts = pd.to_numeric(raw, errors="coerce")
|
|
309
409
|
# Silently reading an unreadable count as zero would drop that carcass from the plan
|
|
310
410
|
# without a word, and a fractional one cannot be assigned at all (the solver would call
|
|
311
411
|
# the plan infeasible and blame the pattern limits), so both are refused here.
|
|
312
|
-
|
|
313
|
-
bad = [int(i) for i, ok in enumerate(
|
|
412
|
+
valid_counts = counts.notna() & np.isfinite(counts) & (counts >= 0) & (counts <= 2**53 - 1) & (counts == np.floor(counts))
|
|
413
|
+
bad = [int(i) for i, ok in enumerate(valid_counts) if not ok]
|
|
314
414
|
if bad:
|
|
315
415
|
shown = ", ".join(f"row {i}: {_plain(raw.iloc[i])!r}" for i in bad[:5])
|
|
316
416
|
raise ValueError(
|
|
@@ -346,6 +446,8 @@ def build_inputs(
|
|
|
346
446
|
"pieces_per_carcass": scenario.pieces_per_carcass,
|
|
347
447
|
},
|
|
348
448
|
pattern_cuts=pattern_cuts,
|
|
449
|
+
eligibility=evaluate(scenario, carcasses if eligibility_source is None else eligibility_source, product_yield, coverage),
|
|
450
|
+
animal_ids=animal_ids,
|
|
349
451
|
)
|
|
350
452
|
|
|
351
453
|
|
|
@@ -373,6 +475,22 @@ def _cuts_of(pattern: Pattern, members_of: Mapping[str, Sequence[str]]) -> tuple
|
|
|
373
475
|
return tuple(sorted(sold))
|
|
374
476
|
|
|
375
477
|
|
|
478
|
+
def purchase_costs(carcasses: pd.DataFrame, scenario: ScenarioSpec) -> np.ndarray:
|
|
479
|
+
"""Charge the known quote per head, or price its declared purchase kilograms."""
|
|
480
|
+
column, basis = scenario.purchase_input(carcasses.columns)
|
|
481
|
+
if basis is None:
|
|
482
|
+
return np.zeros(len(carcasses))
|
|
483
|
+
unit = "per head" if basis == "per_head" else "per kg"
|
|
484
|
+
prices = (_amounts(carcasses, column, what=f"the purchase price {unit}") if column
|
|
485
|
+
else np.full(len(carcasses), float(scenario.purchase_price)))
|
|
486
|
+
if basis == "per_head":
|
|
487
|
+
return prices
|
|
488
|
+
if not scenario.weight_column:
|
|
489
|
+
raise ValueError("a purchase price per kg needs scenario.weight_column")
|
|
490
|
+
weight = _amounts(carcasses, scenario.weight_column, what="the weight the purchase cost is priced on")
|
|
491
|
+
return weight * prices * scenario.purchase_weight_factor
|
|
492
|
+
|
|
493
|
+
|
|
376
494
|
def _amounts(carcasses: pd.DataFrame, column: str, *, what: str) -> np.ndarray:
|
|
377
495
|
"""A money-relevant column as numbers, refusing the rows that are not a finite amount >= 0."""
|
|
378
496
|
if column not in carcasses.columns:
|
|
@@ -418,10 +536,11 @@ def random_assignment_comparison(
|
|
|
418
536
|
for pattern in inputs.patterns
|
|
419
537
|
)
|
|
420
538
|
solved = assignment is not None
|
|
539
|
+
comparable = inputs.eligibility is None
|
|
421
540
|
if solved:
|
|
422
541
|
counts = assignment.reindex(index=profit.index, columns=patterns).fillna(0.0).astype(float)
|
|
423
542
|
chosen_profit = (profit * counts).sum(axis=1)
|
|
424
|
-
advantage = chosen_profit - expected_profit
|
|
543
|
+
advantage = chosen_profit - expected_profit if comparable else pd.Series(np.nan, index=profit.index)
|
|
425
544
|
else:
|
|
426
545
|
chosen_profit = pd.Series(np.nan, index=profit.index)
|
|
427
546
|
advantage = pd.Series(np.nan, index=profit.index)
|
|
@@ -444,17 +563,22 @@ def random_assignment_comparison(
|
|
|
444
563
|
"expected_labor_cost": float(expected_labor.sum()),
|
|
445
564
|
"expected_purchase_cost": float(expected_purchase.sum()),
|
|
446
565
|
"expected_carcasses_per_pattern": total_carcasses / len(patterns),
|
|
447
|
-
"advantage_over_random": None if not solved else float(advantage.sum()),
|
|
566
|
+
"advantage_over_random": None if not solved or not comparable else float(advantage.sum()),
|
|
567
|
+
**({"respects_eligibility": False} if not comparable else {}),
|
|
448
568
|
}
|
|
569
|
+
if any(pattern.packaging_cost_per_head for pattern in inputs.patterns):
|
|
570
|
+
mean_packaging = sum(float(pattern.packaging_cost_per_head) / len(patterns)
|
|
571
|
+
for pattern in inputs.patterns)
|
|
572
|
+
report["expected_packaging_cost"] = float(mean_packaging * total_carcasses)
|
|
449
573
|
return report, by_carcass
|
|
450
574
|
|
|
451
575
|
|
|
452
|
-
def solve(inputs: CuttingPlanInputs) -> PlanResult:
|
|
576
|
+
def solve(inputs: CuttingPlanInputs, *, options: Mapping[str, float] | None = None) -> PlanResult:
|
|
453
577
|
"""Assign carcasses to patterns: a mixed-integer problem (one pattern per carcass, pattern
|
|
454
578
|
capacities), solved exactly by HiGHS."""
|
|
455
579
|
from beefcot.cutting_plan.solver import solve_assignment
|
|
456
580
|
|
|
457
|
-
return solve_assignment(inputs)
|
|
581
|
+
return solve_assignment(inputs, options=options)
|
|
458
582
|
|
|
459
583
|
|
|
460
584
|
def unusual_inputs(carcasses: pd.DataFrame, warnings) -> dict[str, dict[str, Any]]:
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Pattern eligibility evaluated against source rows and frozen model yields."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Rule:
|
|
14
|
+
kind: str
|
|
15
|
+
name: str
|
|
16
|
+
minimum: float | None = None
|
|
17
|
+
maximum: float | None = None
|
|
18
|
+
values: tuple[str, ...] = ()
|
|
19
|
+
basis: str | None = None
|
|
20
|
+
|
|
21
|
+
def to_dict(self):
|
|
22
|
+
if self.kind == 'category':
|
|
23
|
+
return {'kind': self.kind, 'column': self.name, 'values': list(self.values)}
|
|
24
|
+
target = {'product': self.name, 'basis': self.basis} if self.kind == 'product' else {'column': self.name}
|
|
25
|
+
return {'kind': self.kind, **target, 'min': self.minimum, 'max': self.maximum}
|
|
26
|
+
|
|
27
|
+
def label(self):
|
|
28
|
+
if self.kind == 'category':
|
|
29
|
+
return f'{self.name} in {json.dumps(self.values, ensure_ascii=False)}'
|
|
30
|
+
target = f'{self.name} ({self.basis})' if self.basis else self.name
|
|
31
|
+
low = '−∞' if self.minimum is None else f'{self.minimum:g}'
|
|
32
|
+
high = '+∞' if self.maximum is None else f'{self.maximum:g}'
|
|
33
|
+
return f'{target} in [{low}, {high}]'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_rules(raw, context):
|
|
37
|
+
if not isinstance(raw, (list, tuple)):
|
|
38
|
+
raise ValueError(f'{context}: expected a list of rules')
|
|
39
|
+
rules = []
|
|
40
|
+
for index, item in enumerate(raw, 1):
|
|
41
|
+
where = f'{context}, rule {index}'
|
|
42
|
+
if not isinstance(item, Mapping):
|
|
43
|
+
raise ValueError(f'{where}: expected a mapping')
|
|
44
|
+
kind = item.get('kind')
|
|
45
|
+
if kind not in ('number', 'category', 'product'):
|
|
46
|
+
raise ValueError(f'{where}: choose number, category or product')
|
|
47
|
+
target = 'product' if kind == 'product' else 'column'
|
|
48
|
+
name = item.get(target)
|
|
49
|
+
if not isinstance(name, str) or not name.strip():
|
|
50
|
+
raise ValueError(f'{where}: choose a {target}')
|
|
51
|
+
allowed = {'kind', target, 'values'} if kind == 'category' else {'kind', target, 'min', 'max'}
|
|
52
|
+
if kind == 'product':
|
|
53
|
+
allowed.add('basis')
|
|
54
|
+
if set(item) - allowed:
|
|
55
|
+
raise ValueError(f'{where}: unexpected fields: {sorted(map(str, set(item) - allowed))}')
|
|
56
|
+
if kind == 'category':
|
|
57
|
+
values = item.get('values')
|
|
58
|
+
if not isinstance(values, list) or not values or any(not isinstance(v, str) or not v.strip() for v in values):
|
|
59
|
+
raise ValueError(f'{where}: values must be a nonempty list of category strings')
|
|
60
|
+
rules.append(Rule(kind, name, values=tuple(values)))
|
|
61
|
+
continue
|
|
62
|
+
bounds = []
|
|
63
|
+
for key in ('min', 'max'):
|
|
64
|
+
value = item.get(key)
|
|
65
|
+
if value is not None:
|
|
66
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
67
|
+
raise ValueError(f'{where}: {key} must be a finite number')
|
|
68
|
+
try:
|
|
69
|
+
value = float(value)
|
|
70
|
+
except OverflowError:
|
|
71
|
+
value = math.inf
|
|
72
|
+
if not math.isfinite(value):
|
|
73
|
+
raise ValueError(f'{where}: {key} must be a finite number')
|
|
74
|
+
if kind == 'product' and value < 0:
|
|
75
|
+
raise ValueError(f'{where}: product weight bounds must be nonnegative')
|
|
76
|
+
bounds.append(value)
|
|
77
|
+
low, high = bounds
|
|
78
|
+
if low is None and high is None:
|
|
79
|
+
raise ValueError(f'{where}: enter at least one bound')
|
|
80
|
+
if low is not None and high is not None and low > high:
|
|
81
|
+
raise ValueError(f'{where}: minimum exceeds maximum')
|
|
82
|
+
basis = item.get('basis')
|
|
83
|
+
if kind == 'product' and basis not in ('kg/side', 'kg/carcass'):
|
|
84
|
+
raise ValueError(f'{where}: choose kg/side or kg/carcass')
|
|
85
|
+
rules.append(Rule(kind, name, low, high, basis=basis))
|
|
86
|
+
return tuple(rules)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def source_values(raw, count):
|
|
90
|
+
"""Read exact CSV category spellings without changing the model input table."""
|
|
91
|
+
stream = io.BytesIO(raw) if isinstance(raw, bytes) else raw
|
|
92
|
+
return pd.read_csv(stream, dtype=str, keep_default_na=False, nrows=count)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Eligibility:
|
|
97
|
+
allowed: pd.DataFrame
|
|
98
|
+
rejected: dict[tuple[int, str], list[str]]
|
|
99
|
+
|
|
100
|
+
def table(self, ids, available):
|
|
101
|
+
return pd.DataFrame([
|
|
102
|
+
{'carcass': i, 'animal_id': ids[i], 'pattern': pattern,
|
|
103
|
+
'available': int(available[i]), 'eligible': bool(self.allowed.iloc[i, p]),
|
|
104
|
+
'reasons': json.dumps(self.rejected.get((i, pattern), []), ensure_ascii=False)}
|
|
105
|
+
for i in range(len(ids)) for p, pattern in enumerate(self.allowed.columns)
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
def diagnostics(self, available, patterns, ids):
|
|
109
|
+
unmatched = [
|
|
110
|
+
{'carcass': i, 'animal_id': str(ids[i]), 'head': int(available[i]),
|
|
111
|
+
'reasons': [f'{p}: {reason}' for p in self.allowed.columns
|
|
112
|
+
for reason in self.rejected.get((i, p), [])]}
|
|
113
|
+
for i in np.flatnonzero((available > 0) & ~self.allowed.to_numpy().any(axis=1)).tolist()
|
|
114
|
+
]
|
|
115
|
+
counts = [
|
|
116
|
+
{'pattern': pattern.name, 'eligible_head': int(available[self.allowed[pattern.name].to_numpy()].sum()),
|
|
117
|
+
'min_head': pattern.min_carcasses, 'max_head': pattern.max_carcasses}
|
|
118
|
+
for pattern in patterns
|
|
119
|
+
]
|
|
120
|
+
return {'unmatched_rows': len(unmatched), 'unmatched_head': sum(row['head'] for row in unmatched),
|
|
121
|
+
'unmatched_examples': unmatched[:10], 'patterns': counts}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def evaluate(scenario, source, yields, coverage):
|
|
125
|
+
if not any(pattern.eligibility for pattern in scenario.patterns):
|
|
126
|
+
return None
|
|
127
|
+
allowed = pd.DataFrame(True, index=range(len(source)), columns=[pattern.name for pattern in scenario.patterns])
|
|
128
|
+
rejected = {}
|
|
129
|
+
for pattern in scenario.patterns:
|
|
130
|
+
for rule in pattern.eligibility:
|
|
131
|
+
context = f'pattern {pattern.name!r}, eligibility {rule.label()}'
|
|
132
|
+
if rule.kind == 'product':
|
|
133
|
+
if rule.name not in yields or coverage.get(rule.name) in (None, 'zero', 'external'):
|
|
134
|
+
raise ValueError(f'{context}: product needs a frozen model prediction')
|
|
135
|
+
original = scenario.units.get('yield', 'kg/side')
|
|
136
|
+
factor = 1 if original == rule.basis else 2 if rule.basis == 'kg/carcass' else 0.5
|
|
137
|
+
values = yields[rule.name].reset_index(drop=True) * factor
|
|
138
|
+
else:
|
|
139
|
+
if rule.name not in source:
|
|
140
|
+
raise ValueError(f'{context}: source column is missing')
|
|
141
|
+
values = source[rule.name].reset_index(drop=True)
|
|
142
|
+
missing = values.isna() | values.astype(str).str.strip().eq('')
|
|
143
|
+
if rule.kind == 'category':
|
|
144
|
+
valid = values.astype(str).isin(rule.values) & ~missing
|
|
145
|
+
failure = 'category is not allowed'
|
|
146
|
+
else:
|
|
147
|
+
numbers = pd.to_numeric(values, errors='coerce').to_numpy(dtype=float)
|
|
148
|
+
valid = np.isfinite(numbers) & ~missing.to_numpy()
|
|
149
|
+
if rule.minimum is not None:
|
|
150
|
+
valid &= numbers >= rule.minimum
|
|
151
|
+
if rule.maximum is not None:
|
|
152
|
+
valid &= numbers <= rule.maximum
|
|
153
|
+
failure = 'value is outside the range or is not a finite number'
|
|
154
|
+
for row in np.flatnonzero(~np.asarray(valid)).tolist():
|
|
155
|
+
reason = 'value is missing' if missing.iloc[row] else f'{values.iloc[row]!r}: {failure}'
|
|
156
|
+
rejected.setdefault((row, pattern.name), []).append(f'{rule.label()}: {reason}')
|
|
157
|
+
allowed.at[row, pattern.name] = False
|
|
158
|
+
return Eligibility(allowed, rejected)
|