feat(fusion_iot): add setpoint/optimum + deviation to sensor schema

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-20 16:36:58 -04:00
parent 089cda71fe
commit 118f96dad4
8 changed files with 146 additions and 10 deletions

View File

@@ -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 '

View File

@@ -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)'),

View File

@@ -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 {

View File

@@ -10,15 +10,22 @@
<field name="model">fp.tank.reading</field>
<field name="arch" type="xml">
<list string="Sensor Readings"
decoration-danger="not in_spec" default_order="reading_at desc">
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'">
<field name="reading_at"/>
<field name="sensor_id"/>
<field name="tank_id" optional="show"/>
<field name="parameter_id" optional="hide"/>
<field name="display_value"/>
<field name="display_unit"/>
<field name="deviation_from_target"/>
<field name="deviation_band" widget="badge"/>
<field name="value" optional="hide" string="Value (°C raw)"/>
<field name="in_spec" widget="boolean_toggle"/>
<field name="in_spec" widget="boolean_toggle" optional="show"/>
<field name="source" optional="hide"/>
<field name="hold_id" optional="show"/>
</list>

View File

@@ -69,13 +69,19 @@
<field name="parameter_id" options="{'no_create': True}"/>
</group>
</group>
<group string="Alerting">
<group>
<group string="Setpoint &amp; Alerting">
<group string="Target (setpoint)">
<field name="target_value_override"
help="Leave 0 to inherit from bath parameter's Default Setpoint. This is the IDEAL operating value — not an alarm threshold."/>
<field name="effective_target" readonly="1"/>
<field name="effective_target_unit" readonly="1"/>
</group>
<group string="Alarm band">
<field name="alert_on_out_of_spec"/>
<field name="alert_min_override"
help="Leave 0 to inherit from the bath parameter's target_min."/>
help="Leave 0 to inherit from the bath parameter's target_min. Readings below this fire a quality hold."/>
<field name="alert_max_override"
help="Leave 0 to inherit from the bath parameter's target_max."/>
help="Leave 0 to inherit from the bath parameter's target_max. Readings above this fire a quality hold."/>
</group>
<group string="Most Recent Reading">
<field name="last_reading_display" readonly="1"/>

View File

@@ -24,6 +24,8 @@
<field name="device_kind"/>
<field name="device_serial"/>
<field name="parameter_id"/>
<field name="effective_target" readonly="1"/>
<field name="effective_target_unit" readonly="1"/>
<field name="last_reading_display"/>
<field name="last_reading_display_unit"/>
<field name="last_reading_at" readonly="1"/>

View File

@@ -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': """

View File

@@ -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,