====== Useful programs ======
===== Bit functions =====
You can use Lua BitOp extension functions in your programs:
bit.tobit(x) -- normalize number to the numeric range of
-- bit operations (all bit ops use this implicitly)
bit.tohex(x[,n]) -- convert x to hex with n digits (default 8)
bit.bnot(x) -- bitwise not of x
bit.band(x1[,x2...]) -- bitwise and of x1, x2, ...
bit.bor(x1[,x2...]) -- bitwise or of x1, x2, ...
bit.bxor(x1[,x2...]) -- bitwise xor of x1, x2, ...
bit.lshift(x, n) -- left-shift of x by n bits
bit.rshift(x, n) -- logical right-shift of x by n bits
bit.arshift(x, n) -- arithmetic right-shift of x by n bits
bit.rol(x, n) -- left-rotate of x by n bits
bit.ror(x, n) -- right-rotate of x by n bits
bit.bswap(x) -- byte-swap of x (little-endian <-> big-endian)
To access idividual bits, you can use following handy functions:
function bw(n)
return 2 ^ (n - 1) -- returns weight of the bit in pos. n
end
function hasbit(x, b)
local p = bw(b)
return x % (p + p) >= p -- returns if b is true/false; if hasbit(value, b) then ...
end
function setbit(x, b)
return hasbit(x, b) and x or x + bw(b) -- sets bit b in х example: х = setbit(х, b))
end
function clearbit(x, b)
return hasbit(x, b) and x - bw(b) or x -- clears bit b in х
end
function togglebit(x, b) -- toggles bit b in x
if hasbit(x, b) then
return clearbit(x, b)
else
return setbit(x, b)
end
end
function outbit(condition, x, b) -- output bool condition as 0 /1 into a bit
if condition then
return hasbit(x, b) and x or x + bw(b)
else
return hasbit(x, b) and x - bw(b) or x
end
end
function outBit(condition, alias, b) -- output bool condition as 0 /1 into a bit of a internal register
local new_value = outbit(condition, R(alias), b)
W(alias, new_value)
end
function setBit(alias, b) -- sets bit in the register using alias or id
local old_value = R(alias)
local new_value = setbit(old_value, b)
if (new_value ~= old_value) then
WriteReg(alias, new_value) -- to prevent unnecessary writing
end
end
function clearBit(alias, b) -- uses alias or id and crears bit in it
local old_value = R(alias)
local new_value = clearbit(old_value, b)
if (new_value ~= old_value) then
WriteReg(alias, new_value)
end
end
function toggleBit(alias, b) -- toggles bit b in reg by alias or id
local cur_value = R(alias)
if hasbit(cur_value, b) then
clearBit(alias, b)
else
setBit(alias, b)
end
return true
end
-- return bit from the position as a 1 / 0
function getBit(alias, b)
if hasbit(R(alias), b) then
return 1
else
return 0
end
end
To enhance readablity of complex program you may wrap actions made with the register of a bit type to their handy analogs. Then in a complex program you can quickly determine where bit varialbe is changed, checked etc.
-- this function to make shorter ifs operator for bit registes
function TRUE(reg)
return (R(reg)== 1)
end
function NOT(reg)
return (R(reg) == 0)
end
function SET(reg)
W(reg, 1)
end
function RESET(reg)
W(reg, 0)
end
function OUT(condition, reg)
-- shorter analog of setting / resetting bit register dependnig on condition
if condition then
W(reg, 1)
else
W(reg , 0)
end
end
==== Examples of using bit functions ====
=== Writing condition value into some bit ===
In the following example, a condition result is written right into the register mask. This could be used e.g. for status register, where each bit corresponds to some system alert while providing overall system status as 0 / >1 (no bit set(no alert), some bit set (there is an alert.)). This status register can be used in Level2 system to show the node's state.
-- sets bit in certain register depending on condition
include "bit.lib"
function main (userId)
outBit(not R("someRegister"), "alertMask", 15)
end
===== Table functions =====
-- converts bytes to integer value
table.int = function(t)
local HEX_NUMBERING = 16
return tonumber(table.hexStr(t), HEX_NUMBERING)
end
-- converts a table to hex view
function table.hexStr(hextab, spacer)
local hex = {}
for _, hexbyte in ipairs(hextab) do
table.insert(hex, getHexByteAsStr(hexbyte))
end
return table.concat(hex, spacer)
end
-- converts tab to bcd number
table.bcd = function(t)
local hex = table.hexStr(t)
local out = tonumber(hex)
return out
end
-- gets 2-char hex string from a byte
function getHexByteAsStr(inputByte)
local strByte = string.format("%X", inputByte)
return (#strByte == 1 and '0' .. strByte) or strByte
end
-- reverses a table
table.reverse = function (tab)
local outTable = {}
for i = #tab, 1, -1 do
table.insert(outTable, tab[i])
end
return outTable
end
--[[ find an element in a table
@ t - input table , v - value to find
--> value index of false (if not found)
--]]
function table.find(t, v)
for i, tv in pairs(t) do
if (tv == v) then
return i
end
end
return false
end
--[[ finds pattern in a table
@ t - input table , pttrn - pattern to find
--> index of the first byte following the pattern
--]]
function table.findPattern(t, pttrn)
for tabIndex, _ in ipairs(t) do
local matchFlag = true
for seqIndex, sequenceByte in ipairs(pttrn) do -- checking pttrn inside a t
matchFlag = matchFlag and (sequenceByte == t[tabIndex + seqIndex - 1])
if (not matchFlag) then
matchFlag = false
break
end
end
if matchFlag then
return (tabIndex + #pttrn)
end
end
return false
end
--[[ checks if the table has an elem
@ t - table , test
--> index of false (if not found)
--]]
function table.some(t, test)
for i, tv in pairs(t) do
if (test(tv)) then
return true
end
end
return false
end
function table.sub(t, s, e)
local newTab = {}
if (s and e) then
for i = s, e do
table.insert(newTab, t[i])
end
end
return newTab
end
--[[ convenient print function for tables
@ t - table, indent - indent between key & value
--]]
function tprint(t, indent)
if not indent then indent = 0 end
for k, v in pairs(t) do
local formatting = string.rep(' ', indent) .. k .. ': '
if type(v) == "table" then
ERROR(formatting)
tprint(v, indent + 1) -- recursive call
else
if type(v) == "boolean" then
v = v and "TRUE" or "FALSE"
end
ERROR(formatting .. v)
end
end
end
===== Compare and trigger =====
This is used to detect when the value reach certaint value and make one-time action.
cmp = {}
function cmp : new(reg, level)
self.__index = self
return setmetatable({ reg = reg,
level = level,
flag = false }, self)
end
function cmp : __call(helper)
local regValue = R(self.reg)
if helper(regValue, self.level) then
if (not self.flag) then
self.flag = true
DEBUG("got it !")
return true
end
else
self.flag = false
end
return false
end
function greaterThan(arg1, arg2)
return arg1 >= arg2
end
function main (userId)
if (not detect) then
detect = cmp : new ("dBit2", 1)
end
if detect(greaterThan) then
-- SendTelegramMessage()
end
end
===== Iteraion over indexed registers =====
You can iterate your register in loogs using their alias names, which contain the index, e.g.:
--[[
The program uses random number from 1 to 10 as a base to set 3 consequtive registers
named "indexedRegister1", "indexedRegister2", "indexedRegister3"
--]]
function main (userId)
local startValue = math.random(1, 10)
for i = 1, 3 do
W("indexedRegister" .. i, startValue)
startValue = startValue + i
end
end
The result is shown on the following picture:
{{ :lua:iterating_regs_demo.gif |}}
===== Debug printing =====
Standard functions (INFO and [[ http://docs.webhmi.com.ua/scripts#writing_to_communication_log | other]] ) for writing to communication log and lua console has some drawbacks:
* they expect only one parameter of a string type, so you have to concatenate multiple parameters with '..' operator and add spaces between values
* they don't check input parameters for a nil, so the script won't run after the error line if the 'nil' is met in the parameters being concatenated.
To enhance console and communication log output you may use your own versions based on these standard functions:
--[[ universal print function
prints when global ENABLE_DEBUG is set
@ ... any list
if arguments have | symbol treats data as a 2-row data
"arg1, arg2, arg3 ... ", '|'
, arg1, arg2, arg3 ...
if arg type == table print all using tprint
otherwise prints all arguments in a row
--]]
function DBG(...)
if (not ENABLE_DEBUG) then return end
-- piped printing
local pipePosition = table.find(arg, '|')
if (pipePosition) then
-- make header row
local th = {}
for word in string.gmatch(arg[1], "%S+") do
table.insert(th, word)
end
-- make data row
local td = table.sub(arg, pipePosition + 1, #arg)
if (#th ~= #td) then
ERROR('th & td in DBG are not equal!') ; return
else
for i = 1, #th do
local thl, tdl = string.len(th[i]), string.len(td[i])
local diff = math.abs(thl - tdl)
if (thl > tdl) then
td[i] = wrapToSpaces(td[i], diff)
end
if (tdl > thl) then
th[i] = wrapToSpaces(th[i], diff)
end
end
DEBUG(table.concat(th, ' ')) ; DEBUG(table.concat(td, ' '))
end
return
end
-- table print
if table.some(arg, function (a) return type(a) == 'table' end ) then
tprint(arg) ; return
end
-- print in a row
DEBUG(table.concat(arg, ' '))
end
--[[ add extra spaces on both sides of string
@ s - string
n - number of spaces
--> string wrapped in spaces
--]]
function wrapToSpaces(s, n)
local spaces = string.rep(' ', math.floor(n / 2))
return spaces .. s
.. ((n % 2 == 0) and spaces or spaces .. ' ')
end
Then the print output can be enhanced like this:
{{ network:dbg_table_out.png?direct&600 |}}
The //ENABLE_DEBUG// flag should be global boolean variable in the calling script. You may just to set it when needed and save the script.
ENABLE_DEBUG = true -- false will disable debug print
Sometimes you need to trace how a number of different scripts are running together, but want to filter out unneccesary lines in the communication log. Then you can use internal register of a string type for storing IDs of scripts where you want to enable log output. Inside each script you can check if its ID exists in the "enabled" list or no and control ENABLE_DEBUG variable. E.g.
thisScriptID = 43 -- ID is shown in the script list
local i,_,_ = string.find ( -- use this function to find script ID in the list
R ("enDebugList"), -- read enable string from the int. register
"%s+(".. thisScriptID ..")%s+") -- find a pattern of digit(s) inside spaces
if not i then -- equals to if ( i == nil )
ENABLE_DEBUG = false
else
ENABLE_DEBUG = true
end
For complex tables print, you can use the following function:
function tprint(t, indent)
if not indent then indent = 0 end
for k, v in pairs(t) do
local formatting = string.rep(' ', indent) .. k .. ': '
if type(v) == "table" then
ERROR(formatting)
tprint(v, indent + 1) -- recursive call
else
if type(v) == "boolean" then
v = v and "TRUE" or "FALSE"
end
ERROR(formatting .. v)
end
end -- for
end -- tprint
===== Date & Time operations =====
When you need to select different date - time numbers, you can use the following example:
function main (userId)
local dateTimeTable = os.date("*t", os.time())
INFO(dateTimeTable.hour)
INFO(dateTimeTable.min)
end
-- converting utc timestamp in human readable string
function getDateString(utc)
return os.date("%c", utc)
end
Please refer to the [[ https://www.lua.org/pil/22.1.html| Lua documentation site]].
===== Running hours meter =====
Running hours meter accumulates the time while some equipment has been turned on.
function main (userId)
runHoursMeter ( R ( "runHours_condition" ) == 1 , "runHours")
end
function runHoursMeter ( condition , meter_alias)
--[[ strores the time of prevous call in a _prevTime
when called, if condition is met adds diff. between current time and prevTime to _Acc
recieves prefix (meter_alias) and generates needed register aliases by adding suffix --]]
local now = os.time ()
if condition then
local curAcc = R ( meter_alias .. "_Acc" )
W (meter_alias .. "_Acc" , curAcc + ( now - R ( meter_alias .. "_prevTime" ) ) )
end
W ( meter_alias .. "_prevTime", now ) -- store previous call time
end
Three registers were used to demonstrate its work:
* **runHours_prevTime** - this register stores the time of the previous function call. Type - Unix Type
* **runHours_Acc** - accumulator for the running hours. Type - Double Word UInt, with the "Display as time duration" checkbox set
* **runHours_condition** - to test how the function works from the interface this register is used. Type - Bit.
{{network:run_hours_regs.gif?direct}}
===== Timers =====
You can create your timers preventing jittering unnecessary input, which leads to mulitple alert generation or other action, prolongate some states after they changed.
Below some examples are provided:
***Simple - Ton/Toff timers**. These ones keep their state in the script's global vars, so each time the project restarts, they will be initialized. For most cases that's enough.
***Reset-Restart proof timers**. These timer keep their state in the external registers, so they are tolerant to even project restart. Use these timers for critical control.
==== Simple TON timer ====
-- Timer constructor
Timer = {} ; setmetatable(Timer, {__call = function(self)
local obj = {stamp = os.time(), out = false}
return setmetatable(obj, {__index = self})
end })
-- Timer loop
function Timer : run(in_, delay)
local now = os.time()
if (not in_) then
self.out = false ; self.stamp = now
else
if (not self.out and (now - self.stamp) >= delay) then
self.out = true
end
end
return self.out
end
function Timer : reset() self.out = false ; self.stamp = os.time() end
==== Simple TOFF timer ====
toff_proto = {} toff_proto.__index = toff_proto
function toff_proto.new(delay)
return setmetatable({d = delay,
stamp = os.time(),
out = false,
}, toff_proto)
end
function toff_proto:__call(in_)
local now = os.time()
if (in_ ~= self.out) then
if in_ then
self.out = true -- immediate ton!
else
local diff = now - self.stamp
if diff < self.d then
DEBUG("going to sw off in " .. self.d - diff)
return self.out -- don't update stamp
else
DEBUG("Switched off!") ; self.out = false
end
end
end
self.stamp = now
return self.out
end
==== Universal Timer (TON / TOFF) ====
The timer compares its current state with the input condition:
* current state = 0, input = 1 - the timer will switch on its output after onDelay time. If the offDelay parameter = 0, then it will be "pure" TON timer
* current state = 1, input = 0 - the timer will switch off its output after offDelay time. If the onDelay parameter = 0, then it will be "pure" TOFF timer
Two external registers are needed for this function to work:
* a register with "timer_name" script alias of a //Unix Time// type - to store the time stamp of the rising / falling edge on the input
* a register with "timer_name_out" script alias of of a //Bit// type - to keep timer state between calls (in case of project restart etc. )
Use sensible names for the register script aliases - to associate clearly aliases with the "real" equipment. This will greatly improve code readability. E.g. if you want to use timer to mask some alert condition from pressure sensor #1, you might use "lowP1_AlertTmr"and "lowP1_AlertTmr_out" aliases.
function Timer (bool_input, onDelay, offDelay, tmrAlias)
-- bool sec to ON sec to OFF string alias
local now , nowString, curTimeStamp, curTmrState =
os.time(), os.date("%c", now), R(tmrAlias), TRUE(tmrAlias .."_out")
DBG("Timer State Stamp", "|", tmrAlias, curTmrState, os.date("%c", curTimeStamp))
-- protects from malfunctions on very first run
if curTimeStamp == 0 then
DBG("curTimeStamp was zero in this timer!")
WriteReg(tmrAlias, now)
return nil, 0 -- countdown
end
-- in and output are equal
if (bool_input == curTmrState) then
WriteReg(tmrAlias, now) ; DBG("timer inputs match")
return curTmrState, 0 -- as bool
-- TON
elseif bool_input then
if ((now - curTimeStamp) > onDelay) then
SET(tmrAlias .."_out") ; WriteReg(tmrAlias, now) ; DBG("detected ON state after delay")
return true, 0
else
local countDown = onDelay - (now - curTimeStamp) ; DBG("countdown to On in ", countDown)
return curTmrState, countDown
end
-- TOFF
elseif not bool_input then
if ((now - curTimeStamp) > offDelay) then
RESET(tmrAlias .."_out") ; WriteReg (tmrAlias, now) ; DBG("detected OFF state after delay")
return false, 0
else
local countDown = offDelay - (now - curTimeStamp) ; DBG("countdown to OFF in ", countDown)
return curTmrState, countDown
end
end
end -- Timer func
Note: This function should be called in every scan, i.e. don't put it inside "ifs", otherwise it won't update the
timestamp and may not work properly.
===== Generating alert messages with time - delay =====
Generating alerts is a common thing in remote monitoring application. Becuase the system being monitoring can "balance" on the "alert / normal" point there should be made some time delay to filter alerts, especially when they are sent via messaging systems (e-mails, viber, telegram, sms)
PRESSURE_HIGH_LEVEL = 6.0 -- bar
PRESSURE_AL_DELAY = 60 -- seconds
function main(userID)
local error_condition = R("pressure") >= PRESSURE_HIGH_LEVEL
local alTmrState = R("pAlerTmr_out") -- remember current timer state before calling a Timer function
if Timer(error_condition, PRESSURE_AL_DELAY, 0, "pAlerTmr") then
if alTmrState == 0 then -- alert rise
SendSMS("380679999999", "Pressure is over High Limit!")
end
else
if alTmrState == 1 then -- alert fall
SendSMSMessage("380679999999", "Pressure is normal now.")
end
end
end
This one uses simper timer:
include "timers"
CHAT_ID = 569335646
MSG_DELAY = 5 * 60
function main (userId)
reportConnError(2, MSG_DELAY, CHAT_ID, 'CONN STUCK ON NODE https://node-name.webhmicloud.com')
end
function reportConnError(connId, delay, chatId, msg)
if (not tmr) then
tmr = Timer()
end
if tmr : run(GetConnectionErrors(connId) > 0, delay) then
SendTelegramMessage(chatId, msg)
tmr : reset()
end
end
===== Filtering frequent messaging =====
When you want to filter too frequent messaging from WebHMI, e.g. about alerts to the Telegram or else, you can use the following script :
TELEGRAM_BOT_ID = "974482918" -- your telegram chat bot id
-- TELEGRAM_BOT_DDS = "569335646"
curAlertsNum = 0 -- global counter for current active alerts
blockingTimer = {
turnedOn = false,
timeStamp = 0,
-- ADJUST FILTER TIME HERE AS NUMBER_OF_MINUTES * NUMBER_OF_SECONDS
timerDelay = 5 * 60,
}
function blockingTimer : method(argument)
local now = os.time()
if (self.timeStamp == 0) then
self.timeStamp = now -- init for 1st call
end
if (argument == 'set') then
self.turnedOn = true
self.timeStamp = now
end
if self.turnedOn and (now - self.timeStamp > self.timerDelay) then
self.turnedOn = false
else
local countDown = self.timerDelay - (now - self.timeStamp)
DEBUG("masking new alerts with blockingTimer: " .. countDown)
end
return self.turnedOn
end
function main (userId)
local alerts = GetCurrentAlerts()
if (not blockingTimer : method()) and (#alerts > curAlertsNum) then -- number has increased, # - means count table elements
local alertNames = {} ; DEBUG("Alert count has increased!")
for alertIndex, astruc in ipairs(alerts) do
table.insert(alertNames, astruc.title)
end
alertNames = table.concat(alertNames, ', ')
SendTelegramMessage(TELEGRAM_BOT_ID, "Current alerts are: " .. alertNames)
blockingTimer : method('set')
end
curAlertsNum = #alerts
end
===== Moving average =====
The moving average is useful for smoothing the values of parameters that have noises, pulsations so to avoid unneccessary control outputs reacting to unwanted noise.
Algorithm of the moving average:
at the beginning of the filter on the sample, N values are counted by the arithmetic mean, after reaching the end of the sample, one element is discarded (by dividing the sum by the length of the queue), a new one is added instead of it, and the amount is again divided by the length of the queue.
function MovingAverage(reg, filter)
local Q_DEPTH , queue_fill, av_sum = 10 ,
R(filter .. "_fill"),
R(filter .. "_sum")
local in_value, tmp_var, out_value = R(reg), 0, 0 -- read input and initialize some vars
if (queue_fill < Q_DEPTH) then -- queue not filled yet
av_sum = av_sum + in_value -- accumulator
queue_fill = queue_fill + 1 -- inc. index
else -- now the queue full
tmp_var = av_sum / Q_DEPTH -- calc. weight of the one element of the queue
av_sum = av_sum - tmp_var + in_value -- subtract it and add new input value
end
if (queue_fill == Q_DEPTH ) then -- if filled
out_value = av_sum / Q_DEPTH -- calc. as current accumulator / queue lentgh, moving average
else
out_value = av_sum / queue_fill -- not filled then mean average
end
-- remember state
W (filter .. "_sum", av_sum)
W (filter .. "_fill" , queue_fill)
return out_value
end -- MovingAverage
Two registers are required for this script -
* accumulator for the sum, e.g. "//meanOutsideT_sum//"
* current queue index "//meanOutsideT_fill//"
===== ON-OFF control =====
==== Function based ====
include "LIB"
SP, ZONE = 30, 0.5 -- °C
PV_REG, OUT_REG = 21, 50 -- id of the registers with process value and output
function tempControl(sp, pv, zone, out, inZone) -- on - off function ---
local inZone_ = (pv >= (sp - zone)) and (pv <= sp) and TRUE(inZone)
if (pv >= sp) then
SET(inZone)
elseif (pv < (sp - zone) ) then
RESET(inZone)
end
DEBUG("sp pv " .. sp .. ' ' .. pv)
U(out, not (pv >= sp or inZone_) )
end
function main (userId) ------- Main body ---------------------
tempControl(SP, R(PV_REG), ZONE, OUT_REG, "inZoneControl")
end
==== Object programming style ====
include "LIB"
tempControl = {} -- on-off control obj ---------------------------------------
function tempControl : new (pvReg, outputReg, inZoneReg) -- constructor & init
self.__index = self
return setmetatable( { pvReg = pvReg,
out = outputReg,
inZoneReg = inZoneReg
},
self)
end
function tempControl : __call (sp, zone) -- controller
local pv = R(self.pvReg) ; DEBUG("sp pv " .. sp .. ' ' .. pv)
local inZone = (pv >= (sp - zone)) and (pv <= sp) and TRUE(self.inZoneReg)
if (pv >= sp) then
SET(self.inZoneReg)
elseif (pv < (sp - zone) ) then
RESET(self.inZoneReg)
end
U(self.out, not (pv >= sp or inZone))
end
-------------------------- Main ---------------------------------------------------------
SP, ZONE = 30, 0.5 -- °C
PV_REG, OUT_REG = 21, 50 -- id of the registers
function main (userId)
-- creates obj
if (not control) then
control = tempControl : new (PV_REG, OUT_REG, "inZoneControl")
end
control(SP, ZONE)
end
==== Both examples use the following library ====
function TRUE(reg) -- checks if value is 1
return (R(reg) == 1)
end
function NOT(reg) -- checks if value is 0
return (R(reg) == 0)
end
function SET(reg) -- sets reg to 1
local curValue = R(reg)
if curValue and (curValue ~= 1) then
W(reg, 1)
end
end
function RESET(reg) -- sets reg to 0
local curValue = R(reg)
if curValue and (curValue ~= 0) then
W(reg, 0)
end
end
function U(reg, new_value) -- Updates values only when it differs from the target register content
local cur_value = R(reg)
if (cur_value ~= nil and new_value ~= nil) then
if (type(new_value) == 'boolean') then -- protect from boolean value
new_value = new_value and 1 or 0
end
if (new_value ~= cur_value) then
W(reg, new_value)
end
else
ERROR("Could not update value in reg" .. reg .. ' with a value of ' .. tostring(new_value))
end
end
Another way of creating objects:
TimerConstructor = function ()
return setmetatable({}, {
__call = function(self, delay)
local now = os.time()
if (not self.stamp or now - self.stamp >= delay) then
self.stamp = now
return true
else
return false
end
end
}) end
-- usage
function main (userId)
-- creating timer
if (not timer) then
timer = TimerConstructor()
end
if timer (60) then
INFO('timer!')
for i = 1, 8 do
toggle('synapse.do' .. i)
end
end
end
function toggle(v)
W(v, 1 - R(v))
end
===== PID - control =====
An example of implementing a PID controller in WebHMI:
function PID ( sp , pv , c_alias)
DBG ( "Entered PID for ", c_alias )
local now, nexTime = os.time() , R ( c_alias .. ".nextPidTime" )
local CYCLE_TIME = R ( c_alias..".pidCycleTime" )
local ZONE = 0.2 -- can alse be an external setting
-- параметры регулятора
local Kp = R ( c_alias..".Kp" )
local Ti = R (c_alias..".Ki" )
local Td = R (c_alias..".Kd" )
local Int_sum = R ( c_alias..".pidIntegral" ) -- integral part accumulator
local G = R (c_alias .. ".pidOut")
local Err = sp - pv ; DBG ( "current Error = ", Err )
-- add some tolerance for error
if ( math.abs ( Err ) <= ZONE ) then Err = 0 end
DBG(" seconds left for PID cycle = "..tostring(nexTime - now))
if ( now >= nexTime ) then
W ( c_alias..".nextPidTime", now + CYCLE_TIME) -- calc. next cycle time
local dErr = Err - R (c_alias..".pidPrevError" )
-- calc. integral limit
local iSUM_LIMIT = G_LIMIT * (Ti / Kp)
--integral part check
if (Ti == 0) then
Int_sum = 0
else
Int_sum = Int_sum + Err
-- check limits of integral part
if Int_sum >= iSUM_LIMIT then
Int_sum = iSUM_LIMIT
elseif Int_sum < 0 then
Int_sum = 0
else
-- undefined if
end
end
G = Round(Kp * (Err + (Int_sum / Ti) + Td * dErr))
-- check output and limit
if (G < 0) then G = 0 end
if (G > G_LIMIT) then G = G_LIMIT end
-- refresh outputs
W (c_alias..".pidPrevError", Err)
W (c_alias..".dErr", dErr)
W (c_alias.. ".pidIntegral", Int_sum)
W (c_alias..".pidOut", G)
end -- time stamp
return G
end -- PID
This algorithm is typical for use in PLCs. Because the regulator is run at regular intervals, i.e. diff. and int. the components are always computed on the same time scale, so it is not necessary to divide and multiply them by time to obtain the derivative and integral, we can select the time constants Ti, Td. In this algorithm, Ti is an inverse quantity (the larger its value, the smaller the contribution of the integral error).
In this example the register are addressed with **"connection.alias"**, i.e. the script will look for register with name "//alias//" in the connection "//connection//".
===== Fixed quantity circulation with PID =====
This algorithm allows to precisely control process variable (temperature, pressure etc. ) with PID control which transfers its output for smooth control to only one power unit (current active unit - A/C, heater, fan section etc.), while other units work at maximal or minimal output.
function CircControl ( sp, pv, alias )
local TOTAL_CH = 3 -- total channels
local G_MIN, G_MAX = 0 , 100 -- limits of pid output
local tmrSwDelay = R ( alias .. "pidCycleTime" ) * 3
local G = PID ( sp, pv, alias) DBG ( "CircControl pidOut = ", G )
local curCh = R ( alias .. "curCh") DBG ( "curCh", curCh )
-- filtering small error
local err = sp - pv
if ( math.abs ( err ) <= 0.2 ) then err = 0 end
local fwTimer = alias .. "fw_Tmr"
DBG ( "fw pre-cond. - ", NOT ( fwTimer .. "_out") , (err < 0), ( G == G_MIN ) )
if Timer ( ( err < 0 ) and ( G == G_MIN ) , tmrSwDelay , 0 , fwTimer ) then
-- if stay too long with negarive error, decrease cnannel
if ( curCh + 1 ) <= TOTAL_CH then
DBG ( "fw condition detected!, curCh now = ", curCh )
curCh = curCh + 1
W ( alias .."curCh", curCh )
-- and set max. output for the lower channel
G = 100 -- for smooth switching
W (alias .. "pidOut", G )
W (alias .. "pidIntegral", 1000)
end
end
local backTimer = alias .. "back_Tmr"
DBG ( "back pre-cond. - ", NOT ( backTimer .. "_out") , (err > 0), ( G == G_MAX ) )
-- if stay too long with positive error, increase cnannel
if Timer ( ( err > 0 ) and ( G == G_MAX ) , tmrSwDelay , 0 , backTimer ) then
if (curCh - 1) >= 1 then
DBG ( "back condition detected!, curCh now = ", curCh )
curCh = curCh - 1
W ( alias .."curCh", curCh )
-- and set low output value for the next channel
-- for smooth switching
G = 0
W (alias .. "pidIntegral", G )
W (alias .. "pidOut", G )
end
end
-- pid output transferred to current channel, lower channels work at minimal, higher
-- channels at maximal output
local ch_values = { 0 , 0 , 0 }
for i = 1 , 3 do
if ( i < curCh ) then
ch_values [i] = G_MIN
elseif ( i == curCh ) then
ch_values [i] = G
elseif ( i > curCh ) then
ch_values [i] = G_MAX
else
DBG ("Undefined if in ch_values assignment, Line:94 ")
end
end -- for
; DBG ( "now channels are:", ShowTable ("%f" , ch_values) )
return ch_values[1], ch_values[2], ch_values[3]
end
===== Time Circulation algorithm (together with redundancy function) =====
This algorithm is used in systems where it is necessary to alternate the operation of mechanisms (pumps, fans, air conditioners) over time, or on the run hour meters. For example, a set of 2 units is used, which must be alternated in time. If an error occurs on some unit, then the algorithm starts working only on the working (redundancy function). An example of setting the required registers is given below:
include "common.lib" ; include "bit.lib" ; ENABLE_DEBUG = true
AL = {LOW_P_BIT = 3, DRY_RUN_BIT = 2}
function main (userId)
local PAUSE_BEFORE_SW_ON = 10 -- sec.
local C_TMR_ON_DELAY = R("pumpCircTime") * 3600 -- 30 sec.
local alerts = R("alertsRegister")
local pressure_Ok = not hasbit(alerts, AL.LOW_P_BIT) and not hasbit(alerts, AL.DRY_RUN_BIT)
local winter = TRUE('winter_season_flag')
local p1_Ok = NOT("oL_relay") and TRUE("p1Auto") and winter and pressure_Ok
local p2_Ok = NOT("p2OL_relay") and TRUE("p2Auto") and winter and pressure_Ok
local leadPump = R('abLeadPump') -- active pump
if not p1_on_tmr then -- sw on timers
p1_on_tmr = ton_proto.new() ; p2_on_tmr = ton_proto.new()
end
local p1_cmd = p1_on_tmr(p1_Ok and (leadPump == 1), PAUSE_BEFORE_SW_ON)
OUT(p1_cmd, "pumpOnOff")
local p2_cmd = p2_on_tmr(p2_Ok and (leadPump == 2), PAUSE_BEFORE_SW_ON)
OUT(p2_cmd, "p2OnOff")
DBG("winter alerts pressure_Ok p1_Ok p2_Ok p1_cmd p2_cmd", '|',
winter, alerts, pressure_Ok, p1_Ok, p2_Ok, p1_cmd, p2_cmd)
local circTmr = Timer(p1_Ok and p2_Ok, C_TMR_ON_DELAY, 0, "pumpCirculationTmr")
if circTmr then
leadPump = (leadPump == 1) and 2 or 1
RESET("pumpCirculationTmr_out")
end
if (p1_Ok and not p2_Ok) then leadPump = 1 end
if (p2_Ok and not p1_Ok) then leadPump = 2 end
if not (p1_Ok or p2_Ok) then leadPump = 0 end
if (p1_Ok and p2_Ok and (leadPump == 0)) then
leadPump = 1
end
UpdReg("abLeadPump", leadPump)
end
Also here you need a script that will expose the flags of errors of work on certain conditions, read the status of the protection devices, the error registers on the interface, and so on.
===== 3-point control for a valve or servo=====
A 3-point method is used to control the position of the valve, servo, gate valve, etc., when 3 wires are used to control the drive - 'common', 'power UP', 'power - DOWN'. Such drives may or may not be equipped with end position sensors. Sometimes, in the absence of position sensors and low requirements for positioning accuracy, an algorithm can be used when the drive leaves down or up (either one position sensor or one command for a time longer than the valve's full travel time), initializes the coordinate, and then go to the specified position.
To determine intermediate positions, a calculated value is used, determined from the characteristics of the 'full path time', which can also be determined experimentally.
Below is a variant of 3-point control for a valve withoutlimit switches.
function valveControl(v)
local FULL_PATH_TIME = R(v .. "pathTime") ; local KOEF = 100 / FULL_PATH_TIME
local HOMING_DELAY = R(v .. "homingDelay") * 60 * 60 -- get seconds from hours
local auto, autoOpenCmd, autoCloseCmd, now =
TRUE("auto_mode"), TRUE(v .. "openCmd"), TRUE(v .. "closeCmd"), os.time()
local motionTmr = function() -- calc. path quant passed from last call
local quant = 0
if (autoOpenCmd or autoCloseCmd) then
quant = (now - R(v .. "motionTmr")) * KOEF ; SET(v .. "valveStatus") -- opening
if autoCloseCmd then
quant = quant * (-1) -- closing
UpdReg(v .. "valveStatus", 2)
end
else
RESET(v .. "valveStatus")
quant = 0
end -- if motion
UpdReg(v .. "motionTmr", now) -- remember last call
return quant
end -- motionTmr
local pos = Limiter(Round(R(v .. "curPosition")) + motionTmr(), 0, 100)
local sp = R(v .. "posSetpoint")
local uShoot, oShoot = (pos < sp), (pos > sp)
local homingOut = TRUE(v .. "homingTmr_out")
local homing = Timer(not homingOut, HOMING_DELAY, FULL_PATH_TIME * 2, v .. "homingTmr")
if not homingOut and homing then
AddInfoMessage("Started homing for valve " .. v)
end
OUT(auto and uShoot and not homing or (not auto and TRUE(v .. "manOpenCmd")), v .. "openCmd")
OUT(auto and oShoot or homing or (not auto and TRUE(v .. "manCloseCmd")), v .. "closeCmd")
UpdReg(v .. "curPosition", pos)
DBG("auto sp pos autoOpenCmd autoCloseCmd uShoot oShoot ", '|',
auto, sp, Round(pos, 1), autoOpenCmd, autoCloseCmd, uShoot, oShoot)
end
===== Other handy functions =====
-- round a value with set precision
function Round (num, numDecimalPlaces)
local mult = 10^(numDecimalPlaces or 0)
return math.floor(num * mult + 0.5) / mult
end
-- checks if value within range
function InRange (min, max, value)
if (value >= min) and (value <= max) then
return true
else
return false
end
end
-- scaling based on y = kx + b formula
function linearCalc ( value, x1, x2 , y1, y2 )
local k = (y1 - y2 ) / (x1 - x2 )
local b = y1 - k * x1
local y = k * value + b
return y
end
function GetTimeFromHHMM(arg1, arg2 , type, incYearFlag) -- get UNIX time from the month day hour minute
-- type HHMM - using hour minutes
-- type mmdd - using day month
local dateTable = os.date("*t", now)
local result = 0
--[[
produces the table
{year = 1998, month = 9, day = 16, yday = 259, wday = 4,
hour = 23, min = 48, sec = 10, isdst = false}
--]]
if (type == "HHMM") then
dateTable.hour = arg1
dateTable.min = arg2
dateTable.sec = 0
return os.time(dateTable)
elseif (type == "mmdd") then
dateTable.day = arg1
dateTable.month = arg2
dateTable.sec = 0
if incYearFlag then
dateTable.year = dateTable.year + 1
end
result = os.time(dateTable)
DEBUG("GetTimeFromHHMM going to return this - "..os.date("%c",result))
return result
else
ERROR("Undefined if in GetTimeFromHHMM")
end
end -- GetTimeFromHHMM
function U(reg, new_value) -- U = UpdateReg
local cur_value = R(reg)
if (cur_value ~= nil and new_value ~= nil) then
if (type(new_value) == 'boolean') then
new_value = new_value and 1 or 0 -- to update bit registers from boolean conditions exressions
end
if (new_value ~= cur_value) then
W(reg, new_value)
end
else
ERROR("Could not update value in reg" .. reg .. ' with a value of ' .. tostring(new_value))
end
end
function P_TRIG(var, previous)
-- возваращет pos / neg если было обнаружено изменение
-- false - если не было зименения
-- пред. состояние запоминается в previous
DEBUG("P_TRIG was called with this " .. var .. " " ..previous)
local curVar = R(var)
if not curVar then return false end
local prevVar = R(previous) ; DEBUG(" p_trig " .. curVar .. " " .. prevVar)
W (previous, curVar)
if (curVar ~= prevVar) then
if curVar > prevVar then
return "positive"
else
return "negative"
end
end
return false -- nothing happened
end -- P_TRIG
-- multiple ids or aliases read, returns table, have to be unpack() 'ed
-- usage: v1, v2, v3... = unpack(mRead(1, 2, 3, ...))
function mRead(...)
local results = {}
for i = 1, #arg do
local param = arg[i]
-- INFO("processing " .. param .. " paramter")
results[i] = GetReg(param)
end
if (#results == 1) then
return results[1]
else
return results
end
end
===== PLC - like control =====
If you are an experienced user of PLC and get used to Ladder Diagram or FBD languge, in the beginning you may feel difficult to adapt to writing the same algorithms in WebHMI script editor.
But using above mentioned handy funtions and writing your own analogs of your PLC function you can get almost the same short and clear code almost identical to ladder diagram.
E.g. let's consider this ladder network from Siemens Tia Portal, which enables running Pump 1 and Pump 2 depending on day time and other conditions:
{{ network:pump1-2_condition.png?direct&700 |}}
The same logic could be written like this -
function main (userId)
-- Pump 1 & 2 logic
local if_pump1_2_run = (TRUE("nightTime")
or
TRUE("halfPeadEnable") and InRange(R("hPeakStart"), R("hPeakEnd"), os.time ())
or
TRUE("enableCheckPumps")) and TRUE("genAutoMode") and NOT("criticalAlert")
OUT(if_pump1_2_run, "pump1_OnCmd")
OUT(if_pump1_2_run, "pump2_OnCmd")
end
==== Self-masking filter for the physical button (one-shot) ====
Sometimes it is important to control a series of actions with a guaranteed delay between them, i.e.avoid "sticking" action together when the actions are initiated with a button.
The button or other signal may have duration in time, but this signal is used for one-shot pulse to trigger next action. In the ladder program, this could be done like this:
{{ network:self_mask_ladder.png?direct&700 |}}
Using handy lua functions from above, it could be written in this way:
function main (userId)
-- after pressing physical button
-- generate one-scan event and then block itself for set delay
local SELF_MASK_DELAY = 8 -- seconds
local enCond = TRUE("button") and NOT ("lockTmr_out") ; OUT(enCond , "evTrigger")
if enCond then
AddInfoMessage("triggered button and masked itself for " .. SELF_MASK_DELAY .. ' ' .. "seconds")
end
Timer(TRUE("button" ), 0 , SELF_MASK_DELAY , "lockTmr")
end
The video how this code works on registers:
{{ network:self_lock_demo.gif |}}
You can check how the script worked in the messages log (the second message was generated from the event with enabled 'Add log message' option):
{{ network:messages_one_shot.png?direct&814 |}}
==== Detecting rise / fall edge of the signals ====
function P_TRIG(var, previous)
-- detect rising or falling state of the register var
-- false for nil input
-- previous regoster value stored in previous register (id or alias)
DEBUG("P_TRIG was called with this " .. var .. " " ..previous)
local curVar = R(var)
if not curVar then return false end
local prevVar = R(previous) ; DEBUG(" p_trig " .. curVar .. " " .. prevVar)
W(previous, curVar)
if (curVar ~= prevVar) then
if (curVar > prevVar) then
return "positive"
else
return "negative"
end
end
return false -- nothing happened
end -- P_TRIG
-- usage example
function main(userId)
if P_TRIG("pulseId", "pulsePreviousValue") == "positive" then
local counterNewValue = R("counterId") + 1
W("counterId", counterNewValue)
end
end
-- Another version using global callable object
P_TRIG = setmetatable({}, -- callable objects like P_TRIG_FOR_COUNTER ...
{__call = function(self, regToTrack)
-- init section
local newValue = R(regToTrack)
if (not self.previousValue) then
self.previousValue = newValue
return false
end
-- main logic
local result
if (self.previousValue ~= newValue) then
if (newValue > self.previousValue) then
result = 'rise'
else
result = 'fall'
end
else
result = false
end
self.previousValue = newValue -- store previous value
return result
end } )
-- usage
function main(userId)
local ptrig = P_TRIG("testReg2") -- only one call in scan is correct!
if (ptrig == "rise") then
DEBUG("rise detected!")
elseif (ptrig == "fall") then
DEBUG("fall detected!")
else
DEBUG("nothing detected!")
end
end
===== Curve handler =====
This is the library. For more information about the what it is and how to use it click here: [[curves|Curves]]
function GetCurveValue ( curve_register, x_to_find_y )
--[[
--Curve handler
INPUT:
Put curve register as first argument and X coordinate as second.
OUTPUT:
Get as result status if it is inside curve range of outside(false) and the Y value as second output argument
EXAMPLE:
curve_status, value = GetCurveValue( "curve_for_current_hour", current_hour )
--]]
table = cjson.decode( R ( curve_register ) )
-- inRangeCurve( table, x_to_find_y )
for _, value in ipairs( table ) do --piecewise handler inside range of the curve
if ( value.range[1] <= x_to_find_y and x_to_find_y < value.range[2] ) then
-- in_range_status, index_curve_piece, y = true, index, curveLinearCalc( x_to_find_y, value.k, value.b )
in_range_status, y = true, ( x_to_find_y * value.k + value.b )
return in_range_status, y
end
end
if x_to_find_y < table[1].range[1] then -- behaivior for outside left-sided
-- in_range_status, index_curve_piece, y = false, 1, curveLinearCalc( table[1].range[1], table[1].k*0, table[1].b ) --table[1].b
in_range_status, index_curve_piece, y = false, 1, ( table[1].range[1] * table[1].k + table[1].b ) --table[1].b
return in_range_status, y
elseif x_to_find_y > table[#table].range[2] then -- behaivior for outside right-sided
-- in_range_status, index_curve_piece, y = false,#table,curveLinearCalc( table[#table].range[2], table[#table].k, table[#table].b ) --table[#table].b
in_range_status, y = false,#table,( table[#table].range[2] * table[#table].k + table[#table].b)
return in_range_status, y
end
return in_range_status, y
end
Example of usage:
include "curves.lib"
function main (userId)
local t = os.date("*t", os.time())
current_hour = tonumber(t.hour)
curve_status, y = GetCurveValue( "curve_for_current_hour", current_hour )
WriteReg("value_for_current_hour", y)
end
===== Detection of change of state =====
Sometimes you may need take actions upon changing any of the registers in a set. The following function returns true at the moment (scan) the value differs from previous value or false if no changes. Also, it can call callback function to make your code more readable.
OnChange = {} ; setmetatable(OnChange, {__call = function(self)
return function(v, cb)
local out = false
if (not self.prev) then
self.prev = v
end
if (self.prev and v ~= self.prev ) then
out = true
if cb then cb() end
end
self.prev = v
return out
end
end
})
function main (userId)
if (not onchange1) then
onchange1 = OnChange()
end
onchange1(R(110), function() INFO("true!") end )
------- Detecting changes of a set of registers ----------------
local regSet = {{110, function()
INFO("I'm a callback for reg 110")
end },
{1, function()
INFO("I'm a callback for reg 1")
end },
{200, function()
INFO("I'm a callback for reg 200")
end },
}
local handlers = {}
-- registereing handlers
if (not handlers[regSet[1][1]]) then
for _, s in ipairs(regSet) do
handlers[s[1]] = OnChange()
end
end
-- using handlers
for _, s in ipairs(regSet) do
local reg, func = s[1], s[2]
handlers[reg](R(reg), func())
end
end
===== Filters =====
In some projects, due to bad quality of the comm. lines your values can flicker like "3.14" (good one) and "-" (no data). That may confuse the users of the system and spoil graph curves.
To filter these values for the safe timeout, you can use the script below.
The script shold be set as running on each scan and will do the following:
- use the table with pairs of `register to be filetered - its safe copy` and respective counter
- if the value is read just copy it to the safe copy,
- otherwise, start counting errors for this particular register
- write invalid value after a nubmer of failed attempts
READ_ATTEMPTS_BEFORE_ERR = 10 -- scan count before setting invalid value
INVALID_VALUE = -100000 -- invalid value sign
filter = { {reg = 1, safeCopy = 2, errCount = 0}, -- reg - source register
{reg = 3, safeCopy = 4, errCount = 0} } -- safeCopy - last read ok value
function main (userId)
runFilter(filter)
end
function runFilter(f)
for _, fstruc in ipairs(f) do
local curVal = R(fstruc.reg)
if (curVal) then
W(fstruc.safeCopy, curVal)
fstruc.errCount = 0
else
if (fstruc.errCount >= READ_ATTEMPTS_BEFORE_ERR) then
W(fstruc.safeCopy, INVALID_VALUE)
else
fstruc.errCount = fstruc.errCount + 1
end
end
end
end
===== Processing double float numbers =====
function main (userId)
local test_hi = 0x3FBF98AD
local test_lo = 0x0A8FA5FB
INFO("d2float = " .. d2float(test_hi, test_lo))
end
function d2float(hi, lo) ----------------------- CONVERT DOUBLE FLOAT TO FLOAT ---------------------------
local function getNumberFromTab(tab, start, length) -- get a number from table
local result_str = ""
for i = start, (start + length - 1) do
result_str = result_str .. tostring(tab[i])
end
return tonumber(result_str,2)
end
local function dw_to_bits(hex_dw)
local binMapper = {[0] = '0000', [1] = '0001', [2] = '0010', [3] = '0011',
[4] = '0100', [5] = '0101', [6] = '0110', [7] = '0111',
[8] = '1000', [9] = '1001', [0xA] = '1010', [0xB] = '1011',
[0xC] = '1100', [0xD] = '1101', [0xE] = '1110', [0xF] = '1111'
}
local hex_str = string.format("%X", hex_dw)
local len_remainder = 8 - #hex_str
hex_str = (len_remainder > 0 and string.rep('0', len_remainder) .. hex_str) or hex_str
local out = ''
for i = 1, #hex_str do
local d = tonumber(hex_str:sub(i, i), 16)
local tetra = binMapper[d]
out = out .. tetra
end
INFO(out)
return out
end
local NaN = tonumber("11111111111111111111111111111111", 2)
local result_str = dw_to_bits(hi), dw_to_bits(lo)
local result_tab = {}
for i = 1, #result_str do
local b = result_str:sub(i, i)
table.insert(result_tab, b)
end
local sign, exp, mantissa = 0, 0, 0
local fraction_table = {} -- fraction part table
sign = ((result_tab[1] == "1") and -1) or 1 -- get sign
exp = getNumberFromTab(result_tab, 2, 11) -- get exp
for i = 13, 64 do
table.insert(fraction_table, result_tab[i]) -- mantissa
end
for j = 1, 52 do
if (fraction_table[j]== "1") then
mantissa = mantissa +(2 ^(-1 * j)) -- calc. mantissa by summing individual bits
end
end
mantissa = mantissa + 1
local result_num = sign * (2 ^ (exp - 1023)) * mantissa
----------------------------------------- exceptions ----------------------------------
if exp == 0 then -- subnormals
result_num = sign*(2^(-1022))*(mantissa-1)
end
if exp == 0x7ff then -- nan
result_num = NaN;
end
return result_num
end
You can test the example above [[https://www.binaryconvert.com/result_double.html?decimal=048046049050051052050051052|here]]
{{network:d2float_test_screen.png?800|}}
{{network:d2float_test_scree2.png?800|}}