From 118f96dad4bc849740de6899561e788cdb6cddc9 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 20 Apr 2026 16:36:58 -0400 Subject: [PATCH] feat(fusion_iot): add setpoint/optimum + deviation to sensor schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sensors previously only tracked alarm thresholds (alert_min/alert_max). Missing the third piece of standard process control: the SETPOINT — what the heater/chiller controls toward and what dashboards compare against. Without it an operator can't tell whether 89°C is "on target" or "barely still in spec". Schema changes: **fusion.plating.bath.parameter** (shop-wide default) - New `target_value` field — the default setpoint for this parameter across the shop (e.g. 87°C for ENP bath). Parallel to existing target_min / target_max. **fp.tank.sensor** (per-sensor override) - New `target_value_override` — per-sensor override, zero = inherit from parameter. Matches the existing override pattern for alert thresholds so users can fine-tune per-tank without touching the shop-wide spec. - New `effective_target` / `effective_target_unit` computed — resolves override → parameter default, converts to company-preferred unit. - New `_get_setpoint()` helper for internal use. **fp.tank.reading** - New `deviation_from_target` — signed Δ from the sensor's effective setpoint, in the company's preferred unit. Positive = above, negative = below, zero if no setpoint defined. - New `deviation_band` (selection: on/near/far/out/none) — coarse band for fast visual scanning. `on` = within ±1° of target, `near` = ±3°, `far` = beyond, `out` = actually out of the alarm band. **Views** - Sensor form: split the alerting panel into two groups — "Target (setpoint)" on the left, "Alarm band" on the right. Makes the distinction between "where we want to be" and "where we'd panic" visually obvious. - Reading list: new Δ + band columns, with decoration classes (success/info/warning/danger) so the list reads at a glance. - Tank form Sensors tab: inline setpoint + unit column. Seeded: parameter "Bath Temperature (Hot Process)" now carries target_value=87°C as a realistic ENP shop default. Sensors inherit unless they set their own override. Design decisions kept simple: - Did NOT add a warning band (warn_min/warn_max). Two-tier model (setpoint + alarm band) is enough for the pilot. Can add soft warnings later as a separate commit if ops wants them. - Did NOT auto-control heaters. Setpoint is stored as metadata only; actual heater actuation via IoT is a future phase C project. Verified: setpoint 87°C stored → displays 188.60°F on the live pilot sensor (company pref = F). Each incoming reading correctly computes signed deviation; bands colour the reading list appropriately. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_iot/fusion_plating_iot/__manifest__.py | 2 +- .../models/fp_tank_reading.py | 60 +++++++++++++++++++ .../models/fp_tank_sensor.py | 58 +++++++++++++++++- .../views/fp_tank_reading_views.xml | 11 +++- .../views/fp_tank_sensor_views.xml | 14 +++-- .../views/fusion_plating_tank_views.xml | 2 + fusion_plating/fusion_plating/__manifest__.py | 2 +- .../models/fp_bath_parameter.py | 7 +++ 8 files changed, 146 insertions(+), 10 deletions(-) diff --git a/fusion_iot/fusion_plating_iot/__manifest__.py b/fusion_iot/fusion_plating_iot/__manifest__.py index 929ef302..a1bb846a 100644 --- a/fusion_iot/fusion_plating_iot/__manifest__.py +++ b/fusion_iot/fusion_plating_iot/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — IoT Integration', - 'version': '19.0.0.2.0', + 'version': '19.0.0.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Wire physical tank sensors to Fusion Plating — live ' 'temperature / chemistry readings with auto quality holds ' diff --git a/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py b/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py index 0035eb8e..8d982d3f 100644 --- a/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py +++ b/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py @@ -95,6 +95,66 @@ class FpTankReading(models.Model): r.display_value = r.value r.display_unit = r.parameter_id.uom or '' + # ------------------------------------------------------------------ + # Deviation from setpoint — signed Δ from the sensor's effective target + # in the company-preferred unit. Zero if no setpoint defined. + # ------------------------------------------------------------------ + deviation_from_target = fields.Float( + string='Δ from Setpoint', + compute='_compute_deviation', + digits=(12, 2), + help='Signed difference between this reading and the sensor\'s ' + 'setpoint, in the company-preferred unit. Positive = above ' + 'setpoint, negative = below. Zero if no setpoint is defined.', + ) + deviation_band = fields.Selection( + [ + ('none', 'No setpoint'), + ('on', 'On target (±1°)'), + ('near', 'Near target (±3°)'), + ('far', 'Far from target (>3°)'), + ('out', 'Out of spec'), + ], + string='Band', + compute='_compute_deviation', + help='Coarse band for quick visual scanning in lists.', + ) + + @api.depends('value', 'display_value', 'in_spec', + 'sensor_id', 'sensor_id.target_value_override', + 'sensor_id.parameter_id.target_value', + 'sensor_id.parameter_id.parameter_type') + def _compute_deviation(self): + pref = self.env.company.x_fc_default_temp_uom or 'C' + for r in self: + sensor = r.sensor_id + if not sensor: + r.deviation_from_target = 0.0 + r.deviation_band = 'none' + continue + raw_sp = sensor._get_setpoint() + if not raw_sp: + r.deviation_from_target = 0.0 + r.deviation_band = 'none' + continue + # Convert setpoint + value to display unit for the delta so the + # number shown matches what operators expect (°F if pref=F). + ptype = (sensor.parameter_id.parameter_type or '').lower() + if ptype == 'temperature' and pref == 'F': + sp_disp = raw_sp * 9.0 / 5.0 + 32.0 + else: + sp_disp = raw_sp + delta = r.display_value - sp_disp + r.deviation_from_target = round(delta, 2) + if not r.in_spec: + r.deviation_band = 'out' + elif abs(delta) <= 1.0: + r.deviation_band = 'on' + elif abs(delta) <= 3.0: + r.deviation_band = 'near' + else: + r.deviation_band = 'far' + source = fields.Selection( [ ('iot_proxy', 'IoT Proxy (Pi)'), diff --git a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py index ee734cc7..c0004daf 100644 --- a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py +++ b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py @@ -76,12 +76,24 @@ class FpTankSensor(models.Model): ) # ------------------------------------------------------------------ - # Alerting behaviour + # Target / alerting behaviour — three concepts: + # setpoint → what we CONTROL TOWARD (dashboards, PID, trend baseline) + # alert min → lower alarm boundary (quality hold fires below) + # alert max → upper alarm boundary (quality hold fires above) + # + # All three have a parameter-level default + per-sensor override + # pattern so users can fine-tune per-tank without touching the + # shop-wide spec. Zero on an override field = "inherit from parameter". # ------------------------------------------------------------------ + target_value_override = fields.Float( + string='Setpoint / Optimum (override)', digits=(10, 4), + help='Optional per-sensor override of the parameter\'s default ' + 'setpoint. Leave 0 to inherit from bath.parameter.target_value.', + ) alert_on_out_of_spec = fields.Boolean( string='Alert on Out-of-Spec', default=True, help='If checked, a fusion.plating.quality.hold is auto-created ' - 'when a reading falls outside the parameter target range.', + 'when a reading falls outside the alert_min/alert_max band.', ) alert_min_override = fields.Float( string='Alert Min (override)', digits=(10, 4), @@ -94,6 +106,37 @@ class FpTankSensor(models.Model): 'specific sensor.', ) + # ------------------------------------------------------------------ + # Effective target — resolves override → parameter default → 0 + # ------------------------------------------------------------------ + effective_target = fields.Float( + string='Effective Setpoint', + compute='_compute_effective_target', + digits=(12, 2), + help='The setpoint currently in use, after applying any per-sensor ' + 'override. Displayed in the company-preferred unit.', + ) + effective_target_unit = fields.Char( + string='Setpoint Unit', + compute='_compute_effective_target', + ) + + @api.depends('target_value_override', 'parameter_id.target_value', + 'parameter_id.parameter_type', 'parameter_id.uom') + def _compute_effective_target(self): + pref = self.env.company.x_fc_default_temp_uom or 'C' + for rec in self: + raw = rec.target_value_override or ( + rec.parameter_id.target_value if rec.parameter_id else 0.0 + ) + ptype = (rec.parameter_id.parameter_type or '').lower() + if ptype == 'temperature' and pref == 'F': + rec.effective_target = raw * 9.0 / 5.0 + 32.0 if raw else 0.0 + rec.effective_target_unit = '°F' + else: + rec.effective_target = raw + rec.effective_target_unit = rec.parameter_id.uom or '' + # ------------------------------------------------------------------ # Cached latest-reading fields (for quick display in list views) # ------------------------------------------------------------------ @@ -163,6 +206,17 @@ class FpTankSensor(models.Model): ) return (lo or 0.0, hi or 0.0) + def _get_setpoint(self): + """Canonical (raw) setpoint used for deviation calcs. + + Returns 0.0 if no setpoint is set at either level — callers should + treat 0 as "no target defined, skip deviation display". + """ + self.ensure_one() + return self.target_value_override or ( + self.parameter_id.target_value if self.parameter_id else 0.0 + ) or 0.0 + def action_view_readings(self): self.ensure_one() return { diff --git a/fusion_iot/fusion_plating_iot/views/fp_tank_reading_views.xml b/fusion_iot/fusion_plating_iot/views/fp_tank_reading_views.xml index e2023b95..48fc1de6 100644 --- a/fusion_iot/fusion_plating_iot/views/fp_tank_reading_views.xml +++ b/fusion_iot/fusion_plating_iot/views/fp_tank_reading_views.xml @@ -10,15 +10,22 @@ fp.tank.reading + default_order="reading_at desc" + decoration-danger="deviation_band == 'out'" + decoration-warning="deviation_band == 'far'" + decoration-info="deviation_band == 'near'" + decoration-success="deviation_band == 'on'" + decoration-muted="deviation_band == 'none'"> + + - + diff --git a/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml b/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml index fe03d2ac..249bab91 100644 --- a/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml +++ b/fusion_iot/fusion_plating_iot/views/fp_tank_sensor_views.xml @@ -69,13 +69,19 @@ - - + + + + + + + + help="Leave 0 to inherit from the bath parameter's target_min. Readings below this fire a quality hold."/> + help="Leave 0 to inherit from the bath parameter's target_max. Readings above this fire a quality hold."/> diff --git a/fusion_iot/fusion_plating_iot/views/fusion_plating_tank_views.xml b/fusion_iot/fusion_plating_iot/views/fusion_plating_tank_views.xml index d9a0c04c..6ea521a2 100644 --- a/fusion_iot/fusion_plating_iot/views/fusion_plating_tank_views.xml +++ b/fusion_iot/fusion_plating_iot/views/fusion_plating_tank_views.xml @@ -24,6 +24,8 @@ + + diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index b37c9437..a301d85e 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.5.4.0', + 'version': '19.0.5.5.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_bath_parameter.py b/fusion_plating/fusion_plating/models/fp_bath_parameter.py index 3e980d28..0f6b618f 100644 --- a/fusion_plating/fusion_plating/models/fp_bath_parameter.py +++ b/fusion_plating/fusion_plating/models/fp_bath_parameter.py @@ -61,6 +61,13 @@ class FpBathParameter(models.Model): string='Default Target Max', help='Default target maximum. Per-bath overrides are allowed.', ) + target_value = fields.Float( + string='Default Setpoint / Optimum', + help='The IDEAL operating value — what the heater/chiller controls ' + 'toward, what dashboards compare against. Sits between ' + 'target_min and target_max. Per-sensor override via ' + 'fp.tank.sensor.target_value_override.', + ) warning_tolerance = fields.Float( string='Warning Tolerance %', default=10.0,