I am wondering what best practices should be used when using analog input cards such as those for thermocouples with TwinCAT. Specifically I am interested in:
My attempt was the following for a 4-channel input thermocouple card:
// Term 4 EL3314: thermocouple signals
TcInputValue_CH AT %I* : ARRAY[1..4] OF INT;
TcInputStatus_CH AT %I* : ARRAY[1..4] OF WORD;
{attribute 'TcLinkTo' := 'TIID^Device 1 (EtherCAT)^Term 1 (EK1100)^Term 4 (EL3314)^TC Inputs Channel 1^Value'}
However I have no idea how to do that for an array.TYPE ST_TC :
STRUCT
Temperature : REAL;
Status : WORD;
END_STRUCT
END_TYPE
And a global variable:
"Kiln GVL"
(* Sensor variables *)
EastTC : ST_TC;
WestTC : ST_TC;
// other thermocouples could be added
I then created two programs. The first program is "ReadSensors" which basically helps format the analog input cards values into proper variables. I do this:
// Analog input Thermocouple conversion to Celsius
Kiln.EastTC.Temperature := TO_REAL(IO.TcInputValue_CH[1]) / 10.0;
Kiln.WestTC.Temperature := TO_REAL(IO.TcInputValue_CH[3]) / 10.0;
// Thermocouple Fault detection
Kiln.EastTC.Status := IO.TcInputStatus_CH[1];
Kiln.WestTC.Status := IO.TcInputStatus_CH[3];
The second program is used for the safety check, by looking at the Status word registers and checking each address, I created some error codes Status word e.g. the underrange bit address
VAR
// Error codes
Underrange : WORD := 16#1;
Overrange : WORD := 16#2;
Error : WORD := 16#40;
END_VAR
And then error handling is performed using bitwise AND, so an error is detected and the type can be saved for further processing in for example an HMI alert message.
IF TO_BOOL(Kiln.EastTC.Status AND Error) THEN
Kiln.Error := E_ERROR_MESSAGE.TcError;
// describe error type:
IF TO_BOOL(Kiln.EastTC.Status AND Underrange) THEN
Kiln.TcErrorType := E_TC_ERROR.UnderRange;
END_IF
// describe error type:
IF TO_BOOL(Kiln.EastTC.Status AND Overrange) THEN
Kiln.TcErrorType := E_TC_ERROR.OverRange;
END_IF
END_IF
// repeat for all other channels...
Is my approach valid? It could be quite verbose for reading a lot of analog inputs so I am wondering how it could be optimized. Any advice would be greatly appreciated!
The code compiles and seems to work, though I haven't tested it with real hardware. More than anything, I want to know how to do it more professionally.
Correct way of declaring variables for multiple inputs
I don't really think there is a "correct way" of creating and declaring IO variables. It depends on what you are doing. I personally prefer having all the IO in 1 single structure, usually called ST_<machine name>TerminalInputs/Outputs
, then this structure is placed inside the FB
or Program
that will execute the logic. All the linking from/to IO variables is done within the POU
to it's object that control dedicated parts (either via dependency injection or via inputs and outputs from units declared inside).
For some things, it makes sense to declare the IO inside the FB, but personally I don't do it often since in my case, I always access these variables trough ADS to an "HMI" software, so having access to all the variables in one single place makes things a lot easier.
One thing you should know, there is also an option, for most of Beckhoff EC slaves, to create the PLC data type and link it directly, but keep in mind that if you change PDO the data type will change and a new GUID will be generated with the appropriate structure:
Once created, you can link entire input data to the structure like this:
As you can see, the entire input structure is created, you don't need to "think" about how to parse the status either:
I don't use that feature a lot, but sometimes it is nice. The downside is, as I mentioned, the generated data type might "disappear" if you change PDO.
Correct way of linking said variables to the input channels
I have never used the attribute to link variables before. Maybe it can be useful, but personally I prefer manual linking, and it leaves the "cluster" out of the PLC code, and when your electrician mixes things up you are 2 clicks away from fixing your problem, instead of looking at the table of inputs and getting lost in the process, just my opinion. As far as arrays go, you have an example on this Beckhoff link. Here is the example I am referring to:
{attribute 'TcLinkTo' := '[1].bIn := TIID^Device 1 (EtherCAT)^Term 1 (EK1100)^Term 2 (EL1008)^Channel 4^Input;
[1].bOut := TIID^Device 1 (EtherCAT)^Term 1 (EK1100)^Term 3 (EL2008)^Channel 4^Output;
[2].bIn := TIID^Device 1 (EtherCAT)^Term 1 (EK1100)^Term 2 (EL1008)^Channel 5^Input;
[2].bOut := TIID^Device 1 (EtherCAT)^Term 1 (EK1100)^Term 3 (EL2008)^Channel 5^Output'}
aModule : ARRAY[1..2] OF FB_Module;
Correct way of handling errors through the use of the 'Status' word
I would say that if you want to handle errors for individual channels, you should create a FB for TC module (no copy pasting code), instead of only data types. Inside the FB handle errors and pass the to wherever you want them to appear. As an example here is what this could look like:
TYPE ST_TcInputData :
STRUCT
Temperature : INT;
Status : WORD;
END_STRUCT
END_TYPE
FUNCTION_BLOCK FB_TcHanlder EXTENDS FB_HasInstanceName // Basically i get the instance name and path, look at {attribute 'refelection'}
VAR_INPUT
scalingFactor : INT := 10; // Scaling factor, depends on the precision of the module as well as CoE settings
END_VAR
VAR_OUTPUT
temperature : REAL; // Actual, calculated temperature
error : BOOL;
underrange : BOOL;
overrange : BOOL;
END_VAR
VAR
Inputs AT%I* : ST_TcInputData;
_logger : I_LoggerEx;
END_VAR
VAR
incorrectScalingTrigger : R_TRIG;
errorTrigger : R_TRIG;
END_VAR
Logic
incorrectScalingTrigger(CLK := scalingFactor <= 0);
IF incorrectScalingTrigger.Q THEN
_logger.LogWarning(CONCAT('Incorrect scaling parameter at instance ', THIS^.InstanceName));
END_IF
IF scalingFactor = 0 THEN
// Handle incorrect parameter setting... example
temperature := Inputs.Temperature;
ELSE
temperature := DINT_TO_REAL(Inputs.Temperature) / INT_TO_REAL(scalingFactor);
END_IF
underrange := Inputs.Status.0;
overrange := Inputs.Status.1;
error := Inputs.Status.2;
errorTrigger(CLK := error);
IF errorTrigger.Q THEN
// Log the events, this is just an example of how I use logging, TcEventLogger is probably the more used way
IF underrange THEN
_logger.LogError(CONCAT('An error occurred (underrange) at ', THIS^.InstanceName));
ELSIF overrange THEN
_logger.LogError(CONCAT('An error occurred (overrange) at ', THIS^.InstanceName));
ELSE
_logger.LogError(CONCAT('An error occurred (unspecified) at ', THIS^.InstanceName));
END_IF
END_IF
And your MAIN or anywhere you wish to instantiate these FBs would be reduced to only a few lines of code:
PROGRAM MAIN
VAR
EL3314_Ch1_TcGeneratedData : MDP5001_330_56D5BDE3;
MyTcFunctionBlocks : ARRAY[0..7] OF FB_TcHanlder;
i : INT;
END_VAR
FOR i := 0 TO 7 BY 1 DO
MyTcFunctionBlocks[i](scalingFactor := 10);
END_FOR
At the end of the day, it is all subjective. I change my approach from project to project, maybe I see a way someone else does things and I like it and want to try it. This is just my take on it, hopefully I didn't overcomplicate things, but it is a very vast and opinion based topic imho.
EDIT
For the FB_HasInstanceName
, this is just an abstract FB that implements a thing called reflection, which allows you to get your instance name, I particularly like it for logging since it gives you a source of the log entry. This is not relevant to the question, but regardless, here is a link to my library repo. The result of InstanceName
property is name of the instantiated block, while InstancePath
is full path. This is a bit more advanced, maybe not something to worry about when starting off. Here is an example, what you will get when calling either property like in the example, when extending this FB:
For the question about having AT%I* or AT%Q*
declared inside a function block, here is how it appears in practice:
You can define any input or output variable in ANY FunctionBlock
or Program
, it will the appear in your IO image according to the path to that variable.
Please note that EL3314_Ch1_TcGeneratedData : MDP5001_330_56D5BDE3;
is just there to show an example of how to use the generated data type from TwinCAT3 (as mentioned in original answer). It is only there to create an example of different approaches.