Closing behavioral gaps between pythonSoftIOC and traditional C IOCs: field-change callbacks, universal set(), and optional analog conversion
#204
Replies: 3 comments 2 replies
-
|
I fixed some Mojibake (em-dash — came through as —). Will discuss this with @AlexanderWells-diamond and @coretl when we have the chance. |
Beta Was this translation helpful? Give feedback.
-
|
Thank you for this very thorough proposal. Your reasoning for them is sound; we have had many people tripped up by the way
|
Beta Was this translation helpful? Give feedback.
-
|
Hi everyone, thanks for your quick replies. I apologize for the unreadable characters -- I think I've got rid of them all now in the original post. I'll submit the first PR soon, in case having the actual code helps the discussion. And by the way, the non-ASCII characters were only present in the Markdown documents, not the actual code files, so no risk of breaking anything else. Cheers. Emilio. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hi everyone,
Thanks for creating and sharing pythonSoftIOC -- I find it intuitive, well designed, and easy to use; but it can always be improved, like anything. Hence, I'd like to discuss a few potential additions to the API and behavior, before submitting PRs. Thank you in advance for taking the time to consider this.
I'm a Controls Analyst at the Canadian Light Source (CLS), and I've been using pythonSoftIOC and working to introduce Python-based IOCs to our team. The main barrier is that experienced EPICS developers have doubts when PVs created with pythonSoftIOC don't behave the same way as PVs hosted by a traditional C IOC.
I believe that by closing some of these behavioral gaps, we can make pythonSoftIOC significantly easier to adopt by traditional EPICS programmer.
I have working code for all three proposals below and can submit PRs. I'd like to discuss the ideas first to get feedback before doing so.
Executive Summary (TL;DR)
Three additive, backward-compatible changes -- no modifications to EPICS Base, no breaking changes to existing applications:
on_field_change(field, callback)-- lets Python react when a CA/PVA client writes to any record field (SCAN, PROC, DRVL, DRVH, DESC, ...). UsesasTrapWriteRegisterListener, the same hook as the existing caput logger. ~200 lines added.Universal
set()--set()now works regardless of the SCAN setting. Currently it silently does nothing when SCAN is not "I/O Intr". The fix: checkscanIoRequest()'s return value and fall back toscanOnce()when the I/O Intr list is empty. ~20 lines changed.Optional analog conversion (
convert=True) --builder.aIn()/builder.aOut()gain an opt-inconvert=Trueflag that lets the EPICS RVAL->VAL conversion chain run (LINR, ESLO, EOFF, SMOO, breakpoint tables). Default isFalse-- fully backward compatible. Includes aegu_to_raw()helper for the inverse calculation. ~130 lines added.Test results: 387 passing, 16 skipped (Windows-only). All three proposals have dedicated integration tests.
The current limitations
I understand that the current behavior is a known design consequence of how EPICS records are used in Python-based IOCs. EPICS provides the communication layer and the record infrastructure, but in pSIOC the "driving engine" is Python -- not the EPICS database scanning engine. PVs are essentially shared network variables, and many of the fields that control the original EPICS scanning and processing pipeline are not connected in any meaningful way to the Python side.
For example, in my applications I use periodic
asynciotasks for updates (say, every 1 second). The SCAN field, however, stays atI/O Intr-- which is a requirement forset()to publish immediately. If an operator or another application changes SCAN to something else,set()silently stops working. And if someone writes to.PROCon a Passive record expecting fresh data, they get whatever stale value was last stored -- Python had no way to know processing was requested.Here are the specific concerns I hear from colleagues used to traditional EPICS:
SCAN field is misleading. It says "I/O Intr" but the actual update rate is controlled by Python code that the client can't see or influence through the PV fields. Changing SCAN to "1 second" or "Passive" either does nothing (if
DISP=1) or silently breaks the record.PROC doesn't trigger a fresh read. In a traditional IOC, writing to
.PROCcauses the record to process -- device support reads from the source, and the record publishes a fresh value. In pSIOC,.PROCdoes triggerdbProcess(), but Python has no notification that processing was requested, so there's no opportunity to read fresh data before the record publishes.No way to react to many field changes. If a client writes to fields like SCAN, PROC, DRVL, DRVH, DESC, etc., Python code has no notification mechanism. The existing
on_updatecallback only fires for VAL writes on output records.Although it's possible to work around these issues with different patterns than a traditional IOC, an IOC should behave predictably for standard operations regardless of how it is implemented.
What I'd Like to Propose
I've investigated the EPICS Base and pythonSoftIOC code, and found approaches that address these concerns without modifying EPICS Base and without breaking any existing behavior. I'd like to discuss three changes.
Proposal 1: Field-Change Callbacks (
on_field_change)The gap: Python code cannot react when a CA or PVA client writes to many record fields -- including operationally important ones like SCAN, PROC, DRVL, DRVH, DISA, and others. The existing callbacks (
on_updateandvalidate) only cover VAL writes on output records.The approach: Register a C-level
asTrapWriteListenerthat fires after every external field write, then forward the notification to per-record, per-field Python callbacks.Resulting Python API:
The most obvious use cases are SCAN and PROC -- allowing Python to adjust its update strategy when SCAN changes, or to read fresh data when PROC is poked. But
on_field_changeis general-purpose and enables many other patterns:asTrapWriteMessage)How it works:
The mechanism uses
asTrapWriteRegisterListener()-- the same API that the existing caput logger (EpicsPvPutHook) already uses. A new C function (FieldWriteHook) is registered as a second listener. It fires after the field write completes, reads the new value asDBR_STRING, acquires the GIL, and calls a single Python dispatch function. The Python layer demultiplexes by record name and field name to the registered callbacks.Backward compatibility: Fully backward compatible. The hook only fires if a Python callback has been registered via a new
register_field_write_listener()call made during IOC startup. Records with no registered callbacks are unaffected. The existing caput logger (EpicsPvPutHook) continues to work --asTrapWritesupports multiple listeners.Performance impact: One
dbGetField(DBR_STRING) + GIL acquire + Python function call per external field write. The GIL is released immediately after dispatch. Records without registered callbacks incur only the C-level null-pointer check (if (!py_field_write_callback) return;). No impact on internalset()calls or record processing --asTrapWriteonly fires for CA/PVA client writes.The printf caput logger (
EpicsPvPutHook) can be disabled by passinglog_puts=FalsetoiocInit()to eliminate per-put console overhead while still loading the access-security file needed for field-change callbacks.Proposal 2: Universal
set()viascanOnce()Proposal 1 opens the door to reacting to SCAN changes -- Python can now detect that a client changed SCAN from "I/O Intr" to "5 second" and adjust the source polling rate accordingly. But this creates a new problem: once the record is no longer on
I/O Intr,set()breaks.set()callstrigger(), which callsscanIoRequest(), andscanIoRequest()only processes records on the I/O Intr scan list. If we let users meaningfully change SCAN (which Proposal 1 makes possible), we must also makeset()work regardless of the SCAN setting.Root cause:
trigger()usesscanIoRequest()exclusively. This function iterates the I/O Intr scan list -- when the record has been moved to a different scan list (Passive, periodic, Event), the list is empty andscanIoRequest()is a no-op. The value stored byset()sits unpublished until something else triggersdbProcess().The approach: Use
scanIoRequest()'s return value to detect whether it actually queued processing.scanIoRequest()returns a non-zero bitmask if records were queued, or zero if the scan list was empty. If it returns zero (meaning the record is not onI/O Intr), fall back toscanOnce()-- a public EPICS Base API declared indbScan.h-- which queues the record for immediate processing regardless of SCAN setting.From EPICS Base
dbScan.c:The change to
trigger():Only one path fires:
scanIoRequestreturns non-zero ->queuedis truthy ->scanOnceis skipped. This is the existing fast path, unchanged.scanIoRequestreturns 0 (scan list empty) ->queuedis falsy ->scanOncefires, queuing immediate processing.No duplicate processing. No duplicate monitors. One path or the other, never both.
The C wrapper for
scanOnceis minimal:The
scanIoRequestwrapper also needs a small change -- currently it discards the return value (restype = None). The fix is to setrestype = c_uintso Python can see whether processing was actually queued.Behavior after the change:
set()beforeset()afterscanOnce()scanOnce()scanOnce()scanOnce()The SCAN field shows its true value to clients at all times. There is no interception, no write-back, no flicker. If a client sets SCAN to "5 second", it stays "5 second". Python's
set()publishes immediately viascanOnce(), and the EPICS periodic scan also fires every 5 seconds as expected. Both mechanisms coexist cleanly becausedbScanLockserializes access andrecGblCheckDeadband()prevents duplicate monitors when the value hasn't changed.Backward compatibility: Fully backward compatible. The
scanIoRequestpath is unchanged -- just its return value is now captured. ThescanOncecall only fires whenscanIoRequestdidn't queue anything. All existing applications that useSCAN = "I/O Intr"(the current default) will see no behavioral difference.Performance impact: One
scanOnce()call perset()when SCAN is not "I/O Intr". This queues adbProcess()on theonceTaskthread -- the same cost as any single record processing. When SCAN IS "I/O Intr", no additional cost at all (onlyscanIoRequestfires, as before).Combined with Proposal 1, this enables the full pattern:
Now the SCAN field genuinely controls the update rate: the client changes SCAN, Python is notified via
on_field_change, adjusts its polling, and everyset()publishes immediately viascanOnce(). The EPICS periodic scan also fires at the expected rate. From the client's perspective, this record behaves exactly like a record in a traditional C IOC.Proposal 3: Optional RVAL/Conversion Support for ai/ao (considering adding)
The gap: pythonSoftIOC's
aiandaodevice support returnsNO_CONVERT(rc=2) from_process(). This bypasses the entire RVAL<->VAL conversion chain. About 15 standard fields on ai/ao records are therefore inert: LINR, ESLO, EOFF, EGUF, EGUL, ROFF, ASLO, AOFF, SMOO (ai), OROC/OVAL (ao).This is by design for the typical case where Python already computes engineering-unit values. But it creates a barrier for:
The approach: An optional
convert=Trueparameter onbuilder.aIn()/builder.aOut(). Default isFalse-- fully backward compatible.convertis a mutable property -- can be toggled at any time after iocInit.get(raw=False/True)is defined on all record types for API consistency (get(raw=True)is a no-op for non-convert records).Implementation:
ai._process()writesint(value)toRVALand returnsEPICS_OKinstead ofNO_CONVERT. The CaiRecord.cconversion chain runs unchanged -- not a single line of EPICS Base is modified. For ao,init_recordreturnsEPICS_OKso the Cconvert()runs at initialization; it runs unconditionally on every process cycle regardless.Important design note: This is only meaningful when Python provides integer-scale values (ADC counts, DAC codes, register integers). If Python already computes engineering-unit floats,
convert=False(default) is correct -- the float-to-int cast in RVAL would lose sub-unit precision.That said,
convert=Truecan also serve the case where Python provides engineering-unit values but operators still need to trim them with EOFF. Theegu_to_raw()helper method performs the inverse conversion so Python can write a desired VAL and let the operator's calibration fields apply on top:The cost is one extra
int()truncation per update cycle. For the common case where Python provides raw integer counts, pass the integer directly toset()-- no helper needed. For the "operator-adjustable offset on a Python float" use case, a separate calibration PV at the application layer remains the zero-overhead alternative.I'm including this here because it completes the set of standard ai/ao behaviors that are otherwise unreachable, and because in practice the feedback I hear is that ai/ao records "don't behave normally" -- partly because SMOO, OROC, and calibration fields silently do nothing. Even if most users never need
convert=True, having it available lowers the barrier to adopting pSIOC in hardware-integration roles.Known Limitations
Periodic SCAN causes extra record processing
When SCAN is set to a periodic rate (e.g. "1 second"), the EPICS scan thread calls
dbProcess()on its own schedule. Python'sset()also callsscanOnce()->dbProcess()each time a new value is published. This means the record can be processed by both paths:dbProcess().set()callsscanOnce(), which also queuesdbProcess().dbScanLockserializes access so there is no race condition, andrecGblCheckDeadband()prevents duplicate CA monitors when the value hasn't changed between scans. So clients won't see phantom updates -- but the record does undergo extra processing cycles (the periodic scan re-processes the same value Python already published).This is an acceptable trade-off: the goal is to make
set()work regardless of SCAN, and the extra periodic processing is the same thing that happens in a traditional C IOC when hardware is also pushing values.Important: If a client sets SCAN to a periodic rate and you do not reset it, the record starts processing on the EPICS periodic scan schedule in addition to Python's
set()+scanOnce()processing. This double processing is a known limitation.dbScanLockprevents race conditions andrecGblCheckDeadbandsuppresses duplicate monitors, but the wasted processing cycles are real. Users who allow clients to change SCAN should either useauto_reset_scan(see below) or manually reset SCAN from the Python callback.Mitigation --
auto_reset_scan(implemented)iocInit()now acceptsauto_reset_scan=True. When enabled, the C-level hook resets SCAN back to "I/O Intr" immediately after forwarding the written value to the Python callback -- unless the client wrote "Passive" (a deliberate "stop updating" command that is exempt from the reset).In this model, SCAN acts as a latched command: the client writes "5 second" -> Python sees "5 second" in the callback and adjusts its source polling rate -> SCAN is restored to "I/O Intr" -> the record stays on the I/O Intr scan list at all times.
scanIoRequestremains the only processing path -- no periodic-scan contention, no extra processing cycles.The reset is done inside
dbScanLock/dbScanUnlockwith adbPutto the SCAN field usingDBR_ENUM. EPICS Base'sSPC_SCANspecial processing (scanDelete/scanAdd) fires, correctly moving the record back to the I/O Intr scan list. InternaldbPutdoes not triggerasTrapWrite, so there is no callback loop.With
auto_reset_scan=False(the default), SCAN stays at whatever the client wrote. This preserves backward compatibility but means users are responsible for managing the periodic-scan contention themselves if they care about it.Summary
asTrapWriteRegisterListenerset()set()broken for non-I/O-Intr SCANscanOnce()egu_to_raw()helperAll changes are additive, backward compatible, and use only public EPICS Base APIs. No existing application requires modification.
Test results:
I'd love to hear thoughts, concerns, or architectural suggestions before I submit the PRs.
Thanks for your time!
Emilio H.
Canadian Light Source.
Beta Was this translation helpful? Give feedback.
All reactions