Use rotational discharge for Bento consumption
This commit is contained in:
@@ -4,11 +4,12 @@ calculations:
|
|||||||
type: material_consumption
|
type: material_consumption
|
||||||
version: "1"
|
version: "1"
|
||||||
machine_ref: 5f42a4f6-9ca0-4f6f-9786-40d50a35b230
|
machine_ref: 5f42a4f6-9ca0-4f6f-9786-40d50a35b230
|
||||||
source_mode: area_application
|
source_mode: rotational_discharge
|
||||||
application_signal_refs:
|
rotational_speed_signal_refs:
|
||||||
- 19ea65d2-bd35-4d02-89de-4495583d9026
|
- 6e5d2d94-98f9-4cc1-8a88-7987c6282525
|
||||||
- d9f47615-69cf-4f97-88df-199585764491
|
- cd7385c4-337b-4759-ab32-45d65beaf190
|
||||||
# Transport Auszug Geschwindigkeit Istwert, m/min (also the production gate).
|
specific_discharge_kg_per_rev_m: 3.12
|
||||||
|
# Transport Auszug Geschwindigkeit Istwert, m/min (production gate only).
|
||||||
gate_signal_ref: fef41976-1103-4090-b780-eaeecc02fdfa
|
gate_signal_ref: fef41976-1103-4090-b780-eaeecc02fdfa
|
||||||
gate_threshold: 0.3
|
gate_threshold: 0.3
|
||||||
max_sample_gap_seconds: 20.0
|
max_sample_gap_seconds: 20.0
|
||||||
|
|||||||
+127
-49
@@ -1,60 +1,138 @@
|
|||||||
# Bento 1 fresh bentonite consumption
|
# Bento 1 fresh bentonite consumption
|
||||||
|
|
||||||
`config/bento1-material-consumption.yaml` defines
|
`config/bento1-material-consumption.yaml` defines
|
||||||
`bento1-fresh-bentonite-consumption`. This KPI estimates **fresh bentonite
|
`bento1-fresh-bentonite-consumption`, an estimate of **fresh bentonite consumption**
|
||||||
consumption** from the two fresh-spreader setpoints in g/m². The third spreader
|
from actual spreader roll speeds. Both needle rolls apply sequentially across the
|
||||||
uses recycled/recovered bentonite and is deliberately excluded; it is neither
|
full web width. The recycled/recovered third spreader is excluded.
|
||||||
missing data nor estimated. This is not total bentonite deposited on the product,
|
|
||||||
and setpoints are not a measurement of actual mass flow.
|
|
||||||
|
|
||||||
The generic `area_application` source sums the configured application signals
|
The generic `rotational_discharge` source converts one or more rpm signals to the
|
||||||
and converts them to kg/h using `sum(g/m²) × nominal_width_m × speed_m/min × 60 / 1000`.
|
existing integrator's kg/h: `sum(rpm) × nominal_width_m × specific_discharge_kg_per_rev_m × 60`.
|
||||||
The existing previous-value integrator then accumulates kilograms only while
|
Bento config sets the factor to **3.12 kg/(rev·m)** and uses:
|
||||||
speed is strictly greater than 0.3 m/min. `direct_mass_rate` remains the default
|
|
||||||
for existing configurations, including K7. The persistent state schema and
|
|
||||||
production-order bootstrap/resume behavior are unchanged; each disjoint run
|
|
||||||
starts with a new sample baseline while retaining order totals.
|
|
||||||
|
|
||||||
Width comes from the existing ERP workplace status adapter and generic nominal
|
- Right ACT: `6e5d2d94-98f9-4cc1-8a88-7987c6282525`
|
||||||
width parser. The ERP order must format to the ENLYZE order using
|
- Left ACT: `cd7385c4-337b-4759-ab32-45d65beaf190`
|
||||||
`Bento 1-{production_order}`. Missing/mismatched ERP context or an unparseable
|
|
||||||
width rejects the polling cycle without saving state. ENLYZE product metadata is
|
|
||||||
not used for width. The current ERP context supplies width for all runs of that
|
|
||||||
same order; this assumes the order's article width stays constant. Historical
|
|
||||||
orders no longer present in current ERP workplace status require a separate
|
|
||||||
historical context source for offline replay.
|
|
||||||
|
|
||||||
Run with:
|
ENLYZE now returns physical rpm directly (scaling factor 1.0); no division by
|
||||||
|
1000 is applied. For 3.739 + 3.956 rpm and 5 m width, the rate is 120.042 kg/min
|
||||||
|
(7202.52 kg/h), giving 20.007 kg in ten seconds.
|
||||||
|
|
||||||
```sh
|
Transport speed `fef41976-1103-4090-b780-eaeecc02fdfa` is exclusively the
|
||||||
production-analytics run material-poll \
|
production gate: speed must be strictly greater than 0.3 m/min. It does not
|
||||||
--config config/bento1-material-consumption.yaml \
|
multiply the mass rate. The generic rotational mode also supports omitting
|
||||||
--erp-secrets-file secrets/erp.env
|
`gate_signal_ref` and `gate_threshold`, in which case all valid intervals are
|
||||||
|
active. Bento retains its gate.
|
||||||
|
|
||||||
|
The two old SET sources `19ea65d2-bd35-4d02-89de-4495583d9026` and
|
||||||
|
`d9f47615-69cf-4f97-88df-199585764491` are no longer requested by Bento.
|
||||||
|
`direct_mass_rate` remains the default for K7; `area_application` remains
|
||||||
|
available for other configurations. There is no Bento branch in the integrator.
|
||||||
|
|
||||||
|
The previous-value integration, JSON state format and order bootstrap/resume
|
||||||
|
remain unchanged. Totals accumulate across disjoint runs, with a new baseline
|
||||||
|
at each run boundary, even if the inter-run gap is under 20 seconds. Intervals
|
||||||
|
up to 20 seconds use the preceding rate and gate; longer sample gaps and
|
||||||
|
unobserved run tails are not integrated. Missing/nonfinite source values reject
|
||||||
|
the cycle. Finite negative speeds retain the existing generic signed-rate
|
||||||
|
semantics; no clamping is introduced.
|
||||||
|
|
||||||
|
Width comes from the existing ERP nominal-width parser, with workplace
|
||||||
|
`Bento 1` and order format `Bento 1-{production_order}`. Workplace comparisons
|
||||||
|
use `strip().casefold()`; order matching remains strict. Missing/mismatched ERP
|
||||||
|
context or an unparseable width rejects the cycle without saving state. The
|
||||||
|
current ERP context supplies width for all runs of the same order, assuming
|
||||||
|
constant article width. Historical orders no longer in the ERP workplace view
|
||||||
|
need a historical width source for replay.
|
||||||
|
|
||||||
|
The configured factor is based on the supplied independent historical checks:
|
||||||
|
3.1175 at 5.00 m (article 180305), 3.1193 and 3.1187 at 4.85 m (article 173000),
|
||||||
|
and median 3.1208 at 5.00 m (article 180005; p05–p95 3.0703–3.1558).
|
||||||
|
Tests use realistic rpm values and synthetic samples, including disjoint-run
|
||||||
|
bootstrap and persisted resume. They are not a new replay of recorded ENLYZE data.
|
||||||
|
|
||||||
|
## Old calculation cleanup: review before execution
|
||||||
|
|
||||||
|
No production state or database rows were changed during this implementation,
|
||||||
|
and no service start was requested. The operator reports Bento stopped;
|
||||||
|
sandbox systemd access could not independently confirm that state.
|
||||||
|
The deployed `/etc/systemd/system/production-analytics-bento1.service` specifies
|
||||||
|
`/opt/git-projects/production-analytics/data/state/material` as its state directory.
|
||||||
|
|
||||||
|
The existing state file for machine `5f42a4f6-9ca0-4f6f-9786-40d50a35b230`
|
||||||
|
and order `Bento 1-12026000857` is exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/git-projects/production-analytics/data/state/material/4d6cc209e35bc58887e5b4a3816835006425429e810e0d957f651971723e8c2f.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`erp_workplace: Bento 1` assumes that exact ERP workplace identifier; verify it
|
This identity was verified using the store's SHA-256 of the JSON machine/order
|
||||||
against the deployment's workplace view. The 20-second maximum sample gap is a
|
pair. The file exists but its contents are not readable by the sandbox user.
|
||||||
generic policy shared with K7, intended for a nominal 10-second sampling cadence.
|
The other state file, `6d11088159b8e4fd21543560a54a31a0f86faf22bbf8f16873b228d1903c23f9.json`,
|
||||||
One missing sample is tolerated: the resulting 20-second interval is integrated
|
has not been attributed here and must be left untouched.
|
||||||
using the preceding rate and gate. Gaps greater than 20 seconds are not
|
|
||||||
integrated, so prolonged or repeated gaps can undercount consumption.
|
|
||||||
Missing/nonfinite values from either configured fresh spreader reject the cycle.
|
|
||||||
Intervals exceeding the maximum gap and unobserved run tails are not inferred.
|
|
||||||
|
|
||||||
Before implementation, the calculation was independently validated manually
|
Old PostgreSQL snapshots are in `material_consumption_snapshots`, scoped by both
|
||||||
against the same three real ENLYZE signals over the two runs below, yielding
|
`calculation_id = 'bento1-fresh-bentonite-consumption'` and
|
||||||
approximately **147021.9 kg** of fresh bentonite for `Bento 1-12026000814`.
|
`machine_id = '5f42a4f6-9ca0-4f6f-9786-40d50a35b230'`.
|
||||||
The post-implementation live replay could not be repeated because ENLYZE
|
The table has no calculation-source/version provenance column. Given the stopped
|
||||||
connectivity was temporarily unavailable (DNS resolution failure), not because
|
service and no rpm deployment yet, existing rows in this scope belong to the old
|
||||||
of a known implementation issue. Exact production-path replay remains a
|
calculation. PostgreSQL was unreachable from the sandbox, so exact row counts,
|
||||||
follow-up verification rather than a blocker for this milestone.
|
order/run membership and timestamp bounds remain unverified. Do not mistake
|
||||||
|
this predicate for a completed row inventory. Review the following output first.
|
||||||
|
|
||||||
The regression uses article 180305, description `Bfix NSP 5300, 5,00 x 40 m`
|
Run these **read-only inventory commands** on the deployment host:
|
||||||
(width 5.00 m), and the supplied boundaries of runs
|
|
||||||
`b675818c-4636-4976-be6c-3e0e24da9e8a` and
|
```sh
|
||||||
`25d67424-8346-42d4-b844-75b4097382bd` for order `Bento 1-12026000814`.
|
cd /opt/git-projects/production-analytics
|
||||||
It constructs synthetic samples from the supplied aggregates: 1256.0 active
|
sudo systemctl is-active production-analytics-bento1.service
|
||||||
minutes, 31294.6 m² and 4698.0 g/m², yielding approximately 147021.9 kg.
|
sudo cat data/state/material/4d6cc209e35bc58887e5b4a3816835006425429e810e0d957f651971723e8c2f.json
|
||||||
It verifies bootstrap across both runs and persisted resume without counting the
|
sudo docker compose exec -T timescaledb psql -U production_analytics -d production_analytics -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
inter-run gap. It is an aggregate plausibility regression, not an independent
|
BEGIN READ ONLY;
|
||||||
replay of recorded signals; raw reference samples were not supplied.
|
SELECT production_order, run_id, count(*) AS rows,
|
||||||
|
min(timestamp) AS first_snapshot, max(timestamp) AS last_snapshot,
|
||||||
|
min(consumption_kg), max(consumption_kg)
|
||||||
|
FROM material_consumption_snapshots
|
||||||
|
WHERE calculation_id = 'bento1-fresh-bentonite-consumption'
|
||||||
|
AND machine_id = '5f42a4f6-9ca0-4f6f-9786-40d50a35b230'
|
||||||
|
GROUP BY production_order, run_id ORDER BY first_snapshot;
|
||||||
|
SELECT * FROM material_consumption_snapshots
|
||||||
|
WHERE calculation_id = 'bento1-fresh-bentonite-consumption'
|
||||||
|
AND machine_id = '5f42a4f6-9ca0-4f6f-9786-40d50a35b230'
|
||||||
|
ORDER BY production_order, timestamp;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
After reviewing the inventory, and **only with cleanup authorization**, keep the
|
||||||
|
service stopped and execute this backup-and-delete transaction. The backup table
|
||||||
|
intentionally has no `IF NOT EXISTS`: a repeated invocation fails rather than
|
||||||
|
reusing an older backup. Only rows with the backed-up primary keys are deleted.
|
||||||
|
K7 rows are outside the predicate.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/git-projects/production-analytics
|
||||||
|
sudo docker compose exec -T timescaledb psql -U production_analytics -d production_analytics -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE bento1_material_snapshots_before_rpm_20260907 AS
|
||||||
|
SELECT * FROM material_consumption_snapshots
|
||||||
|
WHERE calculation_id = 'bento1-fresh-bentonite-consumption'
|
||||||
|
AND machine_id = '5f42a4f6-9ca0-4f6f-9786-40d50a35b230';
|
||||||
|
DELETE FROM material_consumption_snapshots AS s
|
||||||
|
USING bento1_material_snapshots_before_rpm_20260907 AS old
|
||||||
|
WHERE s.calculation_id = old.calculation_id AND s.machine_id = old.machine_id
|
||||||
|
AND s.production_order = old.production_order AND s.timestamp = old.timestamp;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
sudo mv -n -- \
|
||||||
|
data/state/material/4d6cc209e35bc58887e5b4a3816835006425429e810e0d957f651971723e8c2f.json \
|
||||||
|
data/state/material/4d6cc209e35bc58887e5b4a3816835006425429e810e0d957f651971723e8c2f.json.before-rpm-20260907
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the original JSON path is absent before restarting; `mv -n` preserves an
|
||||||
|
existing backup and will not overwrite it. If inventory reveals other Bento
|
||||||
|
orders, derive their paths with `JsonMaterialStateStore._path(machine, order)`
|
||||||
|
and review any existing files before moving them too. Do not clear the shared
|
||||||
|
directory. Complete both state and snapshot cleanup before starting the new
|
||||||
|
code: the state schema deliberately does not detect a changed source model.
|
||||||
|
|
||||||
|
A later start bootstraps only the currently open order using its current ERP
|
||||||
|
width. It does not automatically rebuild snapshots for closed historical
|
||||||
|
orders. Reconstructing those requires a separately reviewed historical replay.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class MaterialCalculationConfig:
|
|||||||
version: str
|
version: str
|
||||||
machine_ref: str
|
machine_ref: str
|
||||||
rate_signal_ref: str
|
rate_signal_ref: str
|
||||||
gate_signal_ref: str
|
gate_signal_ref: str | None
|
||||||
gate_threshold: float
|
gate_threshold: float
|
||||||
max_sample_gap_seconds: float
|
max_sample_gap_seconds: float
|
||||||
output_metric: str
|
output_metric: str
|
||||||
@@ -56,6 +56,8 @@ class MaterialCalculationConfig:
|
|||||||
group_by: str
|
group_by: str
|
||||||
source_mode: str = "direct_mass_rate"
|
source_mode: str = "direct_mass_rate"
|
||||||
application_signal_refs: tuple[str, ...] = ()
|
application_signal_refs: tuple[str, ...] = ()
|
||||||
|
rotational_speed_signal_refs: tuple[str, ...] = ()
|
||||||
|
specific_discharge_kg_per_rev_m: float | None = None
|
||||||
erp_workplace: str = ""
|
erp_workplace: str = ""
|
||||||
production_order_format: str = ""
|
production_order_format: str = ""
|
||||||
|
|
||||||
@@ -86,7 +88,11 @@ def load_material_calculation(
|
|||||||
if "type" in entry and entry["type"] != "material_consumption":
|
if "type" in entry and entry["type"] != "material_consumption":
|
||||||
raise CalculationConfigError(f"{prefix}.type: only 'material_consumption' is supported")
|
raise CalculationConfigError(f"{prefix}.type: only 'material_consumption' is supported")
|
||||||
area = entry.get("source_mode", "direct_mass_rate") == "area_application"
|
area = entry.get("source_mode", "direct_mass_rate") == "area_application"
|
||||||
if area:
|
rotational = entry.get("source_mode") == "rotational_discharge"
|
||||||
|
if rotational:
|
||||||
|
entry.setdefault("gate_signal_ref", None)
|
||||||
|
entry.setdefault("gate_threshold", 0.0)
|
||||||
|
if area or rotational:
|
||||||
entry.setdefault("rate_signal_ref", "unused")
|
entry.setdefault("rate_signal_ref", "unused")
|
||||||
missing = required - entry.keys()
|
missing = required - entry.keys()
|
||||||
if missing:
|
if missing:
|
||||||
@@ -94,6 +100,8 @@ def load_material_calculation(
|
|||||||
if entry.keys() - allowed:
|
if entry.keys() - allowed:
|
||||||
raise CalculationConfigError(f"{prefix}: unknown fields (check spelling)")
|
raise CalculationConfigError(f"{prefix}: unknown fields (check spelling)")
|
||||||
for name in sorted(required - {"gate_threshold", "max_sample_gap_seconds"}):
|
for name in sorted(required - {"gate_threshold", "max_sample_gap_seconds"}):
|
||||||
|
if name == "gate_signal_ref" and rotational and entry[name] is None:
|
||||||
|
continue
|
||||||
if not isinstance(entry[name], str) or not entry[name].strip():
|
if not isinstance(entry[name], str) or not entry[name].strip():
|
||||||
raise CalculationConfigError(f"{prefix}.{name} must be a non-empty string")
|
raise CalculationConfigError(f"{prefix}.{name} must be a non-empty string")
|
||||||
for name, expected in (
|
for name, expected in (
|
||||||
@@ -108,31 +116,45 @@ def load_material_calculation(
|
|||||||
entry["max_sample_gap_seconds"], f"{prefix}.max_sample_gap_seconds", positive=True,
|
entry["max_sample_gap_seconds"], f"{prefix}.max_sample_gap_seconds", positive=True,
|
||||||
)
|
)
|
||||||
mode = entry.get("source_mode", "direct_mass_rate")
|
mode = entry.get("source_mode", "direct_mass_rate")
|
||||||
if not isinstance(mode, str) or mode not in {"direct_mass_rate", "area_application"}:
|
if not isinstance(mode, str) or mode not in {
|
||||||
|
"direct_mass_rate", "area_application", "rotational_discharge",
|
||||||
|
}:
|
||||||
raise CalculationConfigError("Unsupported material source_mode")
|
raise CalculationConfigError("Unsupported material source_mode")
|
||||||
if area:
|
if area or rotational:
|
||||||
refs = entry.get("application_signal_refs")
|
refs_name = "rotational_speed_signal_refs" if rotational else "application_signal_refs"
|
||||||
|
other = "application_signal_refs" if rotational else "rotational_speed_signal_refs"
|
||||||
|
if other in entry:
|
||||||
|
raise CalculationConfigError(f"{mode} cannot specify {other}")
|
||||||
|
refs = entry.get(refs_name)
|
||||||
if (not isinstance(refs, list) or not refs
|
if (not isinstance(refs, list) or not refs
|
||||||
or any(not isinstance(ref, str) or not ref.strip() for ref in refs)
|
or any(not isinstance(ref, str) or not ref.strip() for ref in refs)
|
||||||
or len(set(refs)) != len(refs)):
|
or len(set(refs)) != len(refs)):
|
||||||
raise CalculationConfigError(
|
raise CalculationConfigError(
|
||||||
"application_signal_refs must be unique signal strings"
|
f"{refs_name} must be unique signal strings"
|
||||||
)
|
)
|
||||||
if entry["rate_signal_ref"] != "unused":
|
if entry["rate_signal_ref"] != "unused":
|
||||||
raise CalculationConfigError("area_application cannot specify rate_signal_ref")
|
raise CalculationConfigError(f"{mode} cannot specify rate_signal_ref")
|
||||||
for name in ("erp_workplace", "production_order_format"):
|
for name in ("erp_workplace", "production_order_format"):
|
||||||
if not isinstance(entry.get(name), str) or not entry[name].strip():
|
if not isinstance(entry.get(name), str) or not entry[name].strip():
|
||||||
raise CalculationConfigError(f"area_application requires {name}")
|
raise CalculationConfigError(f"{mode} requires {name}")
|
||||||
from production_analytics.context import build_enlyze_production_order
|
from production_analytics.context import build_enlyze_production_order
|
||||||
try:
|
try:
|
||||||
build_enlyze_production_order("0", entry["production_order_format"])
|
build_enlyze_production_order("0", entry["production_order_format"])
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise CalculationConfigError("Invalid production_order_format") from exc
|
raise CalculationConfigError("Invalid production_order_format") from exc
|
||||||
entry["application_signal_refs"] = tuple(refs)
|
entry[refs_name] = tuple(refs)
|
||||||
elif any(name in entry for name in (
|
elif any(name in entry for name in (
|
||||||
"application_signal_refs", "erp_workplace", "production_order_format",
|
"application_signal_refs", "rotational_speed_signal_refs",
|
||||||
|
"erp_workplace", "production_order_format",
|
||||||
)):
|
)):
|
||||||
raise CalculationConfigError("Area context fields require area_application")
|
raise CalculationConfigError("Width context fields require a width-based source mode")
|
||||||
|
if rotational:
|
||||||
|
entry["specific_discharge_kg_per_rev_m"] = finite_number(
|
||||||
|
entry.get("specific_discharge_kg_per_rev_m"),
|
||||||
|
"specific_discharge_kg_per_rev_m", positive=True,
|
||||||
|
)
|
||||||
|
elif "specific_discharge_kg_per_rev_m" in entry:
|
||||||
|
raise CalculationConfigError("Specific discharge requires rotational_discharge")
|
||||||
if entry["id"] in instances:
|
if entry["id"] in instances:
|
||||||
raise CalculationConfigError("Calculation ids must be unique")
|
raise CalculationConfigError("Calculation ids must be unique")
|
||||||
instances[entry["id"]] = MaterialCalculationConfig(**entry)
|
instances[entry["id"]] = MaterialCalculationConfig(**entry)
|
||||||
|
|||||||
@@ -119,3 +119,21 @@ def area_application_rate_kg_per_hour(
|
|||||||
if not isfinite(rate):
|
if not isfinite(rate):
|
||||||
raise ValueError("derived material rate must be finite")
|
raise ValueError("derived material rate must be finite")
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def rotational_discharge_rate_kg_per_hour(
|
||||||
|
rotational_speeds_rpm: Iterable[float], nominal_width_m: float,
|
||||||
|
specific_discharge_kg_per_rev_m: float,
|
||||||
|
) -> float:
|
||||||
|
"""Convert full-width roll speeds and specific discharge to canonical kg/h."""
|
||||||
|
speeds = tuple(rotational_speeds_rpm)
|
||||||
|
if not speeds or not all(isfinite(value) for value in speeds):
|
||||||
|
raise ValueError("rotational speeds must be non-empty and finite")
|
||||||
|
if not isfinite(nominal_width_m) or nominal_width_m <= 0:
|
||||||
|
raise ValueError("nominal width must be finite and positive")
|
||||||
|
if not isfinite(specific_discharge_kg_per_rev_m) or specific_discharge_kg_per_rev_m <= 0:
|
||||||
|
raise ValueError("specific discharge must be finite and positive")
|
||||||
|
rate = sum(speeds) * nominal_width_m * specific_discharge_kg_per_rev_m * 60
|
||||||
|
if not isfinite(rate):
|
||||||
|
raise ValueError("derived material rate must be finite")
|
||||||
|
return rate
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from math import isfinite
|
|||||||
from production_analytics.calculations.material_consumption import (
|
from production_analytics.calculations.material_consumption import (
|
||||||
MaterialSample,
|
MaterialSample,
|
||||||
area_application_rate_kg_per_hour,
|
area_application_rate_kg_per_hour,
|
||||||
|
rotational_discharge_rate_kg_per_hour,
|
||||||
)
|
)
|
||||||
from production_analytics.enlyze.exploration import ExplorationClient
|
from production_analytics.enlyze.exploration import ExplorationClient
|
||||||
|
|
||||||
@@ -89,19 +90,28 @@ class EnlyzeApiGateway:
|
|||||||
*,
|
*,
|
||||||
machine_id: str,
|
machine_id: str,
|
||||||
rate_variable_id: str,
|
rate_variable_id: str,
|
||||||
gate_variable_id: str,
|
gate_variable_id: str | None,
|
||||||
start: datetime,
|
start: datetime,
|
||||||
end: datetime,
|
end: datetime,
|
||||||
application_variable_ids: tuple[str, ...] = (),
|
application_variable_ids: tuple[str, ...] = (),
|
||||||
nominal_width_m: float | None = None,
|
nominal_width_m: float | None = None,
|
||||||
|
rotational_speed_variable_ids: tuple[str, ...] = (),
|
||||||
|
specific_discharge_kg_per_rev_m: float | None = None,
|
||||||
) -> list[MaterialSample]:
|
) -> list[MaterialSample]:
|
||||||
for name, value in (("start", start), ("end", end)):
|
for name, value in (("start", start), ("end", end)):
|
||||||
if value.tzinfo is None or value.utcoffset() is None:
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
raise ValueError(f"{name} must be timezone-aware")
|
raise ValueError(f"{name} must be timezone-aware")
|
||||||
if end < start:
|
if end < start:
|
||||||
raise ValueError("end must not precede start")
|
raise ValueError("end must not precede start")
|
||||||
source_ids = application_variable_ids or (rate_variable_id,)
|
if application_variable_ids and rotational_speed_variable_ids:
|
||||||
variable_ids = list(dict.fromkeys((*source_ids, gate_variable_id)))
|
raise ValueError("Material source modes are mutually exclusive")
|
||||||
|
if gate_variable_id is None and not rotational_speed_variable_ids:
|
||||||
|
raise ValueError("This source requires a gate signal")
|
||||||
|
source_ids = (
|
||||||
|
rotational_speed_variable_ids or application_variable_ids or (rate_variable_id,)
|
||||||
|
)
|
||||||
|
gate_ids = (gate_variable_id,) if gate_variable_id else ()
|
||||||
|
variable_ids = list(dict.fromkeys((*source_ids, *gate_ids)))
|
||||||
request_body = {
|
request_body = {
|
||||||
"machine": machine_id,
|
"machine": machine_id,
|
||||||
"start": start.astimezone(UTC).isoformat(),
|
"start": start.astimezone(UTC).isoformat(),
|
||||||
@@ -123,7 +133,7 @@ class EnlyzeApiGateway:
|
|||||||
raise ValueError(f"required column {required!r} must occur exactly once")
|
raise ValueError(f"required column {required!r} must occur exactly once")
|
||||||
time_index = columns.index("time")
|
time_index = columns.index("time")
|
||||||
source_indices = [columns.index(ref) for ref in source_ids]
|
source_indices = [columns.index(ref) for ref in source_ids]
|
||||||
gate_index = columns.index(gate_variable_id)
|
gate_index = columns.index(gate_variable_id) if gate_variable_id else None
|
||||||
if not isinstance(data["records"], list):
|
if not isinstance(data["records"], list):
|
||||||
raise ValueError("records must be a list")
|
raise ValueError("records must be a list")
|
||||||
for index, record in enumerate(data["records"]):
|
for index, record in enumerate(data["records"]):
|
||||||
@@ -131,13 +141,19 @@ class EnlyzeApiGateway:
|
|||||||
if not isinstance(record, list) or len(record) != len(columns):
|
if not isinstance(record, list) or len(record) != len(columns):
|
||||||
raise ValueError("record must match columns")
|
raise ValueError("record must match columns")
|
||||||
sources = [record[i] for i in source_indices]
|
sources = [record[i] for i in source_indices]
|
||||||
gate = record[gate_index]
|
gate = record[gate_index] if gate_index is not None else 1.0
|
||||||
for value in (*sources, gate):
|
for value in (*sources, gate):
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
raise ValueError("rate and gate must be numeric")
|
raise ValueError("rate and gate must be numeric")
|
||||||
if not isfinite(value):
|
if not isfinite(value):
|
||||||
raise ValueError("rate and gate must be finite")
|
raise ValueError("rate and gate must be finite")
|
||||||
if application_variable_ids:
|
if rotational_speed_variable_ids:
|
||||||
|
if nominal_width_m is None or specific_discharge_kg_per_rev_m is None:
|
||||||
|
raise ValueError("rotational discharge requires width and factor")
|
||||||
|
rate = rotational_discharge_rate_kg_per_hour(
|
||||||
|
sources, nominal_width_m, specific_discharge_kg_per_rev_m,
|
||||||
|
)
|
||||||
|
elif application_variable_ids:
|
||||||
if nominal_width_m is None:
|
if nominal_width_m is None:
|
||||||
raise ValueError("area application requires nominal width")
|
raise ValueError("area application requires nominal width")
|
||||||
rate = area_application_rate_kg_per_hour(sources, nominal_width_m, gate)
|
rate = area_application_rate_kg_per_hour(sources, nominal_width_m, gate)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ class ErpNominalWidthProvider:
|
|||||||
|
|
||||||
def __call__(self, production_order: str) -> float:
|
def __call__(self, production_order: str) -> float:
|
||||||
status = self.gateway.get_current_workplace_status(self.workplace)
|
status = self.gateway.get_current_workplace_status(self.workplace)
|
||||||
if (status is None or status.workplace != self.workplace
|
if (status is None
|
||||||
|
or status.workplace.strip().casefold() != self.workplace.strip().casefold()
|
||||||
or build_enlyze_production_order(status.production_order, self.format_template)
|
or build_enlyze_production_order(status.production_order, self.format_template)
|
||||||
!= production_order):
|
!= production_order):
|
||||||
raise ValueError("ERP context does not match the production order")
|
raise ValueError("ERP context does not match the production order")
|
||||||
|
|||||||
@@ -51,23 +51,30 @@ class MaterialPollingService:
|
|||||||
state_store: MaterialStateStore,
|
state_store: MaterialStateStore,
|
||||||
machine_id: str,
|
machine_id: str,
|
||||||
rate_variable_id: str,
|
rate_variable_id: str,
|
||||||
gate_variable_id: str,
|
gate_variable_id: str | None,
|
||||||
gate_threshold: float,
|
gate_threshold: float,
|
||||||
max_sample_gap_seconds: float,
|
max_sample_gap_seconds: float,
|
||||||
application_variable_ids: tuple[str, ...] = (),
|
application_variable_ids: tuple[str, ...] = (),
|
||||||
|
rotational_speed_variable_ids: tuple[str, ...] = (),
|
||||||
|
specific_discharge_kg_per_rev_m: float | None = None,
|
||||||
nominal_width_provider: Callable[[str], float] | None = None,
|
nominal_width_provider: Callable[[str], float] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if application_variable_ids and rotational_speed_variable_ids:
|
||||||
|
raise ValueError("Material source modes are mutually exclusive")
|
||||||
|
self._rotational_speed_variable_ids = rotational_speed_variable_ids
|
||||||
|
self._specific_discharge = specific_discharge_kg_per_rev_m
|
||||||
self._application_variable_ids = application_variable_ids
|
self._application_variable_ids = application_variable_ids
|
||||||
self._nominal_width_provider = nominal_width_provider
|
self._nominal_width_provider = nominal_width_provider
|
||||||
if application_variable_ids and nominal_width_provider is None:
|
if ((application_variable_ids or rotational_speed_variable_ids)
|
||||||
raise ValueError("area application requires a nominal width provider")
|
and nominal_width_provider is None):
|
||||||
|
raise ValueError("width-based source requires a nominal width provider")
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
self._state_store = state_store
|
self._state_store = state_store
|
||||||
self._machine_id = machine_id
|
self._machine_id = machine_id
|
||||||
self._rate_variable_id = rate_variable_id
|
self._rate_variable_id = rate_variable_id
|
||||||
self._gate_variable_id = gate_variable_id
|
self._gate_variable_id = gate_variable_id
|
||||||
self._config = MaterialIntegratorConfig(
|
self._config = MaterialIntegratorConfig(
|
||||||
gate_threshold=gate_threshold,
|
gate_threshold=gate_threshold if gate_variable_id is not None else 0.0,
|
||||||
max_sample_gap_seconds=max_sample_gap_seconds,
|
max_sample_gap_seconds=max_sample_gap_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +86,7 @@ class MaterialPollingService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
width = (self._nominal_width_provider(run.production_order)
|
width = (self._nominal_width_provider(run.production_order)
|
||||||
if self._application_variable_ids else None)
|
if self._application_variable_ids or self._rotational_speed_variable_ids else None)
|
||||||
saved_polling_state = self._state_store.load(
|
saved_polling_state = self._state_store.load(
|
||||||
self._machine_id,
|
self._machine_id,
|
||||||
run.production_order,
|
run.production_order,
|
||||||
@@ -151,6 +158,11 @@ class MaterialPollingService:
|
|||||||
source_options = dict(
|
source_options = dict(
|
||||||
application_variable_ids=self._application_variable_ids, nominal_width_m=width,
|
application_variable_ids=self._application_variable_ids, nominal_width_m=width,
|
||||||
)
|
)
|
||||||
|
if self._rotational_speed_variable_ids:
|
||||||
|
source_options = dict(
|
||||||
|
rotational_speed_variable_ids=self._rotational_speed_variable_ids,
|
||||||
|
specific_discharge_kg_per_rev_m=self._specific_discharge, nominal_width_m=width,
|
||||||
|
)
|
||||||
samples = self._gateway.get_material_samples(
|
samples = self._gateway.get_material_samples(
|
||||||
machine_id=self._machine_id,
|
machine_id=self._machine_id,
|
||||||
rate_variable_id=self._rate_variable_id,
|
rate_variable_id=self._rate_variable_id,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def build_material_runner(
|
|||||||
) from exc
|
) from exc
|
||||||
postgres_settings = PostgresSettings.from_environment(environment)
|
postgres_settings = PostgresSettings.from_environment(environment)
|
||||||
source_options = {}
|
source_options = {}
|
||||||
if calculation.source_mode == "area_application":
|
if calculation.source_mode in {"area_application", "rotational_discharge"}:
|
||||||
source_options = dict(
|
source_options = dict(
|
||||||
application_variable_ids=calculation.application_signal_refs,
|
application_variable_ids=calculation.application_signal_refs,
|
||||||
nominal_width_provider=ErpNominalWidthProvider(
|
nominal_width_provider=ErpNominalWidthProvider(
|
||||||
@@ -82,6 +82,12 @@ def build_material_runner(
|
|||||||
format_template=calculation.production_order_format,
|
format_template=calculation.production_order_format,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if calculation.source_mode == "rotational_discharge":
|
||||||
|
source_options.pop("application_variable_ids")
|
||||||
|
source_options.update(
|
||||||
|
rotational_speed_variable_ids=calculation.rotational_speed_signal_refs,
|
||||||
|
specific_discharge_kg_per_rev_m=calculation.specific_discharge_kg_per_rev_m,
|
||||||
|
)
|
||||||
service = MaterialPollingService(
|
service = MaterialPollingService(
|
||||||
gateway=EnlyzeApiGateway(ExplorationClient(settings)),
|
gateway=EnlyzeApiGateway(ExplorationClient(settings)),
|
||||||
state_store=_ReportingStateStore(JsonMaterialStateStore(state_directory)),
|
state_store=_ReportingStateStore(JsonMaterialStateStore(state_directory)),
|
||||||
|
|||||||
@@ -80,6 +80,31 @@ def width_provider():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('configured,returned', [
|
||||||
|
('Bento 1', 'BENTO 1'),
|
||||||
|
('Bento 1', ' \tBENTO 1\n'),
|
||||||
|
(' \tBento 1\n', 'BENTO 1'),
|
||||||
|
('K7', ' k7 '),
|
||||||
|
])
|
||||||
|
def test_width_context_normalizes_workplace(configured, returned):
|
||||||
|
provider = width_provider()
|
||||||
|
provider.workplace = configured
|
||||||
|
provider.gateway.get_current_workplace_status.return_value.workplace = returned
|
||||||
|
assert provider('Bento 1-12026000814') == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('production_order', [
|
||||||
|
'Bento 1-12026000815',
|
||||||
|
'BENTO 1-12026000814',
|
||||||
|
' Bento 1-12026000814 ',
|
||||||
|
])
|
||||||
|
def test_width_context_keeps_production_order_strict(production_order):
|
||||||
|
provider = width_provider()
|
||||||
|
provider.gateway.get_current_workplace_status.return_value.workplace = 'BENTO 1'
|
||||||
|
with pytest.raises(ValueError, match='ERP context does not match the production order'):
|
||||||
|
provider(production_order)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('field,value', [
|
@pytest.mark.parametrize('field,value', [
|
||||||
('production_order', '12026000815'), ('workplace', 'K7'),
|
('production_order', '12026000815'), ('workplace', 'K7'),
|
||||||
('article_description', None), ('article_description', '180305'),
|
('article_description', None), ('article_description', '180305'),
|
||||||
@@ -91,10 +116,11 @@ def test_width_context_fails_closed(field, value):
|
|||||||
provider('Bento 1-12026000814')
|
provider('Bento 1-12026000814')
|
||||||
|
|
||||||
|
|
||||||
def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
|
@pytest.mark.parametrize('inter_run_gap_seconds', [5, 3761])
|
||||||
|
def test_bento_rpm_bootstrap_and_persistent_resume(tmp_path, inter_run_gap_seconds):
|
||||||
"""Synthetic aggregate fixture, not recorded ENLYZE samples.
|
"""Synthetic aggregate fixture, not recorded ENLYZE samples.
|
||||||
|
|
||||||
Uses reported active time/area/application within the actual run boundaries.
|
Uses realistic rpm values and synthetic active time within actual run boundaries.
|
||||||
"""
|
"""
|
||||||
config = load_material_calculation('config/bento1-material-consumption.yaml')
|
config = load_material_calculation('config/bento1-material-consumption.yaml')
|
||||||
order = 'Bento 1-12026000814'
|
order = 'Bento 1-12026000814'
|
||||||
@@ -106,7 +132,11 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
|
|||||||
('25d67424-8346-42d4-b844-75b4097382bd',
|
('25d67424-8346-42d4-b844-75b4097382bd',
|
||||||
'2026-08-26T05:43:39Z', '2026-08-26T05:54:05Z'),
|
'2026-08-26T05:43:39Z', '2026-08-26T05:54:05Z'),
|
||||||
]]
|
]]
|
||||||
speed = 31294.6 / (5 * 1256)
|
runs[1] = replace(
|
||||||
|
runs[1], start=runs[0].end + timedelta(seconds=inter_run_gap_seconds),
|
||||||
|
end=runs[0].end + timedelta(seconds=inter_run_gap_seconds + 626),
|
||||||
|
)
|
||||||
|
speed = 2.0
|
||||||
client = Mock()
|
client = Mock()
|
||||||
|
|
||||||
def response(path, body):
|
def response(path, body):
|
||||||
@@ -122,8 +152,8 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
|
|||||||
if start <= stop <= end:
|
if start <= stop <= end:
|
||||||
times.add(stop)
|
times.add(stop)
|
||||||
return SimpleNamespace(body={'data': {
|
return SimpleNamespace(body={'data': {
|
||||||
'columns': ['time', *config.application_signal_refs, config.gate_signal_ref],
|
'columns': ['time', *config.rotational_speed_signal_refs, config.gate_signal_ref],
|
||||||
'records': [[t.isoformat(), 2300, 2398, speed if t < stop else 0]
|
'records': [[t.isoformat(), 3.739, 3.956, speed if t < stop else 0]
|
||||||
for t in sorted(times)],
|
for t in sorted(times)],
|
||||||
}})
|
}})
|
||||||
|
|
||||||
@@ -139,35 +169,44 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
|
|||||||
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
|
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
|
||||||
gate_threshold=config.gate_threshold,
|
gate_threshold=config.gate_threshold,
|
||||||
max_sample_gap_seconds=config.max_sample_gap_seconds,
|
max_sample_gap_seconds=config.max_sample_gap_seconds,
|
||||||
application_variable_ids=config.application_signal_refs,
|
rotational_speed_variable_ids=config.rotational_speed_signal_refs,
|
||||||
|
specific_discharge_kg_per_rev_m=config.specific_discharge_kg_per_rev_m,
|
||||||
nominal_width_provider=width_provider(),
|
nominal_width_provider=width_provider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
partial = service().poll_once(now=runs[1].start + timedelta(seconds=300))
|
||||||
result = service().poll_once(now=runs[1].end)
|
result = service().poll_once(now=runs[1].end)
|
||||||
|
assert result.state.cumulative_consumption_kg - partial.state.cumulative_consumption_kg == (
|
||||||
|
pytest.approx((3.739 + 3.956) * 5 * 3.12 * 326 / 60)
|
||||||
|
)
|
||||||
assert result.state.integrated_running_seconds / 60 == pytest.approx(1256)
|
assert result.state.integrated_running_seconds / 60 == pytest.approx(1256)
|
||||||
assert result.state.cumulative_consumption_kg == pytest.approx(147021.9, abs=0.2)
|
assert result.state.cumulative_consumption_kg == pytest.approx(
|
||||||
assert result.state.integrated_running_seconds / 60 * speed * 5 == pytest.approx(31294.6)
|
(3.739 + 3.956) * 5 * 3.12 * 1256,
|
||||||
|
)
|
||||||
assert service().poll_once(now=runs[1].end).state == result.state
|
assert service().poll_once(now=runs[1].end).state == result.state
|
||||||
assert gateway.get_production_runs.call_count == 1
|
assert gateway.get_production_runs.call_count == 1
|
||||||
assert [(timestamp(call.args[1]['start']), timestamp(call.args[1]['end']))
|
assert [(timestamp(call.args[1]['start']), timestamp(call.args[1]['end']))
|
||||||
for call in client.post_json.call_args_list[:2]] == [
|
for call in client.post_json.call_args_list[:2]] == [
|
||||||
(run.start, run.end) for run in runs]
|
(runs[0].start, runs[0].end),
|
||||||
|
(runs[1].start, runs[1].start + timedelta(seconds=300)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_k7_config_defaults_remain_direct():
|
def test_k7_config_defaults_remain_direct():
|
||||||
config = load_material_calculation('config/k7-material-consumption.yaml')
|
config = load_material_calculation('config/k7-material-consumption.yaml')
|
||||||
assert config.source_mode == 'direct_mass_rate'
|
assert config.source_mode == 'direct_mass_rate'
|
||||||
|
assert config.rotational_speed_signal_refs == ()
|
||||||
assert config.application_signal_refs == ()
|
assert config.application_signal_refs == ()
|
||||||
assert config.gate_threshold == 0.5
|
assert config.gate_threshold == 0.5
|
||||||
assert config.rate_signal_ref == 'c9d06af5-f6d6-4ede-b6c4-5a98bac77129'
|
assert config.rate_signal_ref == 'c9d06af5-f6d6-4ede-b6c4-5a98bac77129'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('field,value', [
|
@pytest.mark.parametrize('field,value', [
|
||||||
('source_mode', 'unknown'), ('application_signal_refs', []),
|
('source_mode', 'unknown'), ('rotational_speed_signal_refs', []),
|
||||||
('application_signal_refs', ['same', 'same']), ('erp_workplace', ''),
|
('rotational_speed_signal_refs', ['same', 'same']), ('erp_workplace', ''),
|
||||||
('production_order_format', '{wrong}'), ('rate_signal_ref', 'direct'),
|
('production_order_format', '{wrong}'), ('rate_signal_ref', 'direct'),
|
||||||
])
|
])
|
||||||
def test_invalid_area_configuration(tmp_path, field, value):
|
def test_invalid_rotational_configuration(tmp_path, field, value):
|
||||||
with open('config/bento1-material-consumption.yaml') as stream:
|
with open('config/bento1-material-consumption.yaml') as stream:
|
||||||
document = yaml.safe_load(stream)
|
document = yaml.safe_load(stream)
|
||||||
document['calculations'][0][field] = value
|
document['calculations'][0][field] = value
|
||||||
@@ -177,7 +216,7 @@ def test_invalid_area_configuration(tmp_path, field, value):
|
|||||||
load_material_calculation(path)
|
load_material_calculation(path)
|
||||||
|
|
||||||
|
|
||||||
def test_area_context_failure_does_not_touch_persistent_state(tmp_path):
|
def test_rotational_context_failure_does_not_touch_persistent_state(tmp_path):
|
||||||
config = load_material_calculation('config/bento1-material-consumption.yaml')
|
config = load_material_calculation('config/bento1-material-consumption.yaml')
|
||||||
gateway = Mock()
|
gateway = Mock()
|
||||||
now = timestamp('2026-08-25T05:27:18Z')
|
now = timestamp('2026-08-25T05:27:18Z')
|
||||||
@@ -191,7 +230,9 @@ def test_area_context_failure_does_not_touch_persistent_state(tmp_path):
|
|||||||
gateway=gateway, state_store=store, machine_id=config.machine_ref,
|
gateway=gateway, state_store=store, machine_id=config.machine_ref,
|
||||||
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
|
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
|
||||||
gate_threshold=config.gate_threshold, max_sample_gap_seconds=20,
|
gate_threshold=config.gate_threshold, max_sample_gap_seconds=20,
|
||||||
application_variable_ids=config.application_signal_refs, nominal_width_provider=provider,
|
rotational_speed_variable_ids=config.rotational_speed_signal_refs,
|
||||||
|
specific_discharge_kg_per_rev_m=config.specific_discharge_kg_per_rev_m,
|
||||||
|
nominal_width_provider=provider,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match='context'):
|
with pytest.raises(ValueError, match='context'):
|
||||||
service.poll_once(now=now)
|
service.poll_once(now=now)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from production_analytics.calculations.config import (
|
||||||
|
CalculationConfigError,
|
||||||
|
load_material_calculation,
|
||||||
|
)
|
||||||
|
from production_analytics.calculations.material_consumption import (
|
||||||
|
MaterialConsumptionIntegrator,
|
||||||
|
MaterialIntegratorConfig,
|
||||||
|
rotational_discharge_rate_kg_per_hour,
|
||||||
|
)
|
||||||
|
from production_analytics.enlyze.gateway import EnlyzeApiGateway
|
||||||
|
from production_analytics.service.material_runtime import build_material_runner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('speeds,width,factor,expected', [
|
||||||
|
([3.739, 3.956], 5, 3.12, 7202.52),
|
||||||
|
([3.739, 3.956], 4.85, 3.12, 6986.4444),
|
||||||
|
([2], 4, 1.5, 720),
|
||||||
|
([2, 3, 4], 4, 1.5, 3240),
|
||||||
|
])
|
||||||
|
def test_conversion(speeds, width, factor, expected):
|
||||||
|
assert rotational_discharge_rate_kg_per_hour(speeds, width, factor) == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('speeds,width,factor', [
|
||||||
|
([], 5, 3.12), ([float('nan')], 5, 3.12), ([1], 0, 3.12),
|
||||||
|
([1], float('inf'), 3.12), ([1], 5, 0), ([1], 5, float('nan')),
|
||||||
|
([1e308], 5, 3.12),
|
||||||
|
])
|
||||||
|
def test_invalid_conversion(speeds, width, factor):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rotational_discharge_rate_kg_per_hour(speeds, width, factor)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('gate_id', ['speed', None])
|
||||||
|
def test_gate_and_gap(gate_id):
|
||||||
|
start = datetime(2026, 8, 25, tzinfo=UTC)
|
||||||
|
times = [0, 10, 20, 40, 61, 71, 81]
|
||||||
|
gates = [2, 0.3, 0, 8, 1, 1, 1]
|
||||||
|
client = Mock()
|
||||||
|
client.post_json.return_value.body = {'data': {
|
||||||
|
'columns': ['time', 'right', 'left'] + ([gate_id] if gate_id else []),
|
||||||
|
'records': [[(start + timedelta(seconds=t)).isoformat(), 3.739, 3.956]
|
||||||
|
+ ([gate] if gate_id else []) for t, gate in zip(times, gates, strict=True)],
|
||||||
|
}}
|
||||||
|
samples = EnlyzeApiGateway(client).get_material_samples(
|
||||||
|
machine_id='m', rate_variable_id='unused', gate_variable_id=gate_id,
|
||||||
|
rotational_speed_variable_ids=('right', 'left'), nominal_width_m=5,
|
||||||
|
specific_discharge_kg_per_rev_m=3.12, start=start, end=start + timedelta(seconds=81),
|
||||||
|
)
|
||||||
|
assert all(s.material_rate_kg_per_hour == pytest.approx(7202.52) for s in samples)
|
||||||
|
state = MaterialConsumptionIntegrator(MaterialIntegratorConfig(0.3, 20)).process_many(samples)
|
||||||
|
seconds = 30 if gate_id else 60
|
||||||
|
assert state.integrated_running_seconds == seconds
|
||||||
|
assert state.cumulative_consumption_kg == pytest.approx(120.042 * seconds / 60)
|
||||||
|
assert client.post_json.call_args.args[1]['variables'] == [
|
||||||
|
{'uuid': ref} for ref in ['right', 'left'] + ([gate_id] if gate_id else [])]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field,value', [
|
||||||
|
('specific_discharge_kg_per_rev_m', None), ('specific_discharge_kg_per_rev_m', 0),
|
||||||
|
('specific_discharge_kg_per_rev_m', True), ('specific_discharge_kg_per_rev_m', float('inf')),
|
||||||
|
('application_signal_refs', ['set']), ('rotational_speed_signal_refs', ['']),
|
||||||
|
])
|
||||||
|
def test_invalid_config(tmp_path, field, value):
|
||||||
|
document = yaml.safe_load(open('config/bento1-material-consumption.yaml'))
|
||||||
|
document['calculations'][0][field] = value
|
||||||
|
path = tmp_path / 'config.yaml'
|
||||||
|
path.write_text(yaml.safe_dump(document))
|
||||||
|
with pytest.raises(CalculationConfigError):
|
||||||
|
load_material_calculation(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_gate_config(tmp_path):
|
||||||
|
document = yaml.safe_load(open('config/bento1-material-consumption.yaml'))
|
||||||
|
del document['calculations'][0]['gate_signal_ref']
|
||||||
|
del document['calculations'][0]['gate_threshold']
|
||||||
|
path = tmp_path / 'config.yaml'
|
||||||
|
path.write_text(yaml.safe_dump(document))
|
||||||
|
config = load_material_calculation(path)
|
||||||
|
assert config.gate_signal_ref is None
|
||||||
|
assert config.gate_threshold == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bento_runtime_wiring(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv('ENLYZE_BASE_URL', 'https://example.invalid/api/')
|
||||||
|
for key, value in dict(HOST='localhost', PORT='5432', DB='analytics',
|
||||||
|
USER='user', PASSWORD='test').items():
|
||||||
|
monkeypatch.setenv(f'POSTGRES_{key}', value)
|
||||||
|
secrets = tmp_path / 'secret.env'
|
||||||
|
secrets.write_text('ENLYZE_API_KEY=test\n')
|
||||||
|
config = load_material_calculation('config/bento1-material-consumption.yaml')
|
||||||
|
with patch('production_analytics.service.material_runtime.ErpSettings') as erp_settings:
|
||||||
|
runner = build_material_runner(
|
||||||
|
config, poll_interval_seconds=10, state_directory=tmp_path / 'state',
|
||||||
|
secrets_file=secrets, erp_secrets_file=tmp_path / 'erp.env',
|
||||||
|
)
|
||||||
|
service = runner.service
|
||||||
|
assert config.source_mode == 'rotational_discharge'
|
||||||
|
assert service._rotational_speed_variable_ids == (
|
||||||
|
'6e5d2d94-98f9-4cc1-8a88-7987c6282525', 'cd7385c4-337b-4759-ab32-45d65beaf190',
|
||||||
|
)
|
||||||
|
assert service._application_variable_ids == ()
|
||||||
|
assert service._specific_discharge == 3.12
|
||||||
|
assert service._gate_variable_id == 'fef41976-1103-4090-b780-eaeecc02fdfa'
|
||||||
|
assert service._config == MaterialIntegratorConfig(0.3, 20)
|
||||||
|
assert service._nominal_width_provider.workplace == 'Bento 1'
|
||||||
|
erp_settings.from_secret_file.assert_called_once_with(tmp_path / 'erp.env')
|
||||||
Reference in New Issue
Block a user