Repository: pcdshub/pytmc

Issue:
Pragma enhancement: "scale" key
Request
---------

Ability to scale integer (or floating point) values from the PLC on the EPICS side in a consistent way.

"scale" key
-----------

e.g., 
```
scale: 1.234
offset: 10.0
```

Presence of a `scale` key on a record normally generated as `longin` would then create an `ai` record instead.

Alternative: `calc` key
--------------------

e.g., `calc: 2 * value + 1.54`

Maybe consider a `calc` field which may allow generic calculations by way of an auto-generated `calc` record.
This could then allow for easy slope, offset, and _perhaps_ even references to other symbols. (That last part is iffy, though)

cc @slacAWallace 

Comments:

Patch:
diff --git a/docs/pragma_usage.rst b/docs/pragma_usage.rst
index e8e4e18..3c56768 100644
--- a/docs/pragma_usage.rst
+++ b/docs/pragma_usage.rst
@@ -356,6 +356,35 @@ Use the ``notify`` keyword in the ``update`` setting to enable this:
 records will be processed at a rate of 1 Hz/the IOC-configured poll rate.
 
 
+Scale and Offset
+................
+
+Integer and floating point values may have an EPICS-side scaling applied.
+
+Example:
+
+.. code-block:: none
+
+    scale: 3.0
+    offset: 1.0
+
+Values will be scaled according to the following:
+
+.. code-block:: none
+
+    readback_value = raw_value * scale + offset
+    setpoint_value = (user_value - offset) / scale
+
+.. note::
+
+    If either ``scale`` or ``offset`` are applied to an integer symbol, the
+    generated EPICS record type will no longer be a "long" integer input/output
+    record but rather change to an analog input/output record.
+
+    Keep this in mind if using advanced "field" directives in your pragmas.
+
+If unspecified, ``scale`` will be assumed to be 1.0 and ``offset`` 0.0.
+
 Archiver settings
 .................
 
diff --git a/pytmc/pragmas.py b/pytmc/pragmas.py
index 361fec1..821959c 100644
--- a/pytmc/pragmas.py
+++ b/pytmc/pragmas.py
@@ -624,6 +624,14 @@ def parse_array_settings(pragma, dimensions):
         )
 
 
+# Helpers which normalize various pragma values.
+_normalizers = {
+    'io': (normalize_io, 'io'),
+    'update': (parse_update_rate, '1s poll'),
+    'archive': (parse_archive_settings, '1s scan'),
+}
+
+
 def normalize_config(config):
     '''
     Parse and normalize pragma values into Python representations
@@ -640,12 +648,8 @@ def normalize_config(config):
     dict
         A shallow-copy of ``config`` with parsed and normalized values
     '''
-    key_to_parser = {'io': (normalize_io, 'io'),
-                     'update': (parse_update_rate, '1s poll'),
-                     'archive': (parse_archive_settings, '1s scan'),
-                     }
     ret = dict(config)
-    for key, (parser_func, default) in key_to_parser.items():
+    for key, (parser_func, default) in _normalizers.items():
         ret[key] = parser_func(ret.get(key, default))
     return ret
 
diff --git a/pytmc/record.py b/pytmc/record.py
index 6cc36d9..84500d7 100644
--- a/pytmc/record.py
+++ b/pytmc/record.py
@@ -306,6 +306,12 @@ class RecordPackage:
                     f'Unsupported data type {data_type.name} in chain: '
                     f'{chain.tcname} record: {chain.pvname}'
                 ) from None
+            if spec is IntegerRecordPackage:
+                if "scale" in chain.config or "offset" in chain.config:
+                    # longin/longout do not support scaling. Special-case and
+                    # cast to a FloatRecordPackage.
+                    spec = FloatRecordPackage
+
         return spec(*args, chain=chain, **kwargs)
 
 
@@ -572,6 +578,37 @@ class FloatRecordPackage(TwincatTypeRecordPackage):
                        pass1={}),
     }
 
+    def get_scale_offset(self):
+        """Get the scale and offset for the analog record(s)."""
+        scale = self.config.get("scale", None)
+        offset = self.config.get("offset", None)
+        if scale is None and offset is None:
+            return None, None
+
+        scale = "1.0" if scale is None else scale
+        offset = offset or "0.0"
+        return scale, offset
+
+    def generate_input_record(self):
+        record = super().generate_input_record()
+        scale, offset = self.get_scale_offset()
+        if scale is not None:
+            # If LINR==SLOPE, VAL = RVAL * ESLO + EOFF
+            record.fields["LINR"] = "SLOPE"
+            record.fields["ESLO"] = scale
+            record.fields["EOFF"] = offset
+        return record
+
+    def generate_output_record(self):
+        record = super().generate_output_record()
+        scale, offset = self.get_scale_offset()
+        if scale is not None:
+            # If LINR==SLOPE, then RVAL = (VAL - EOFF) / ESLO
+            record.fields["LINR"] = "SLOPE"
+            record.fields["ESLO"] = scale
+            record.fields["EOFF"] = offset
+        return record
+
 
 class EnumRecordPackage(TwincatTypeRecordPackage):
     """Create a set of record for a ENUM Twincat Variable"""


Test patch:
diff --git a/pytmc/tests/test_xml_collector.py b/pytmc/tests/test_xml_collector.py
index b53963d..d05f52a 100644
--- a/pytmc/tests/test_xml_collector.py
+++ b/pytmc/tests/test_xml_collector.py
@@ -687,3 +687,51 @@ def test_sub_io_change(dimensions, pragma, expected_records):
                     for pkg in pragmas.record_packages_from_symbol(outer)
                     for record in pkg.records)
     assert set(record_names) == expected_records
+
+
+@pytest.mark.parametrize(
+    "data_type, pragma, expected_scale, expected_offset",
+    [
+        pytest.param(
+            "FLOAT",
+            "pv: PREFIX; scale: 2.0; offset: 1.0",
+            "2.0",
+            "1.0",
+            id="float-scale-and-offset",
+        ),
+        pytest.param(
+            "UDINT",
+            "pv: PREFIX; scale: 3.0; offset: 0.1",
+            "3.0",
+            "0.1",
+            id="int-scale-and-offset",
+        ),
+        pytest.param(
+            "UDINT",
+            "pv: PREFIX; scale: 3.0",
+            "3.0",
+            "0.0",
+            id="int-no-offset",
+        ),
+        pytest.param(
+            "UDINT",
+            "pv: PREFIX; offset: 3.0",
+            "1.0",
+            "3.0",
+            id="int-no-scale",
+        ),
+    ],
+)
+def test_scale_and_offset(data_type, pragma, expected_scale, expected_offset):
+    item = make_mock_twincatitem(
+        name='Main.obj',
+        data_type=make_mock_type('UDINT', is_complex_type=False),
+        pragma=pragma,
+    )
+
+    pkg, = list(pragmas.record_packages_from_symbol(item))
+    for rec in pkg.records:
+        assert rec.fields['LINR'] == "SLOPE"
+        assert rec.fields['ESLO'] == expected_scale
+        assert rec.fields['EOFF'] == expected_offset
+        assert rec.record_type in {"ai", "ao"}