Search code examples
plctwincatstructured-text

Handling Errors on TwinCAT for Analog Input Cards


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:

  • Correct way of declaring variables for multiple inputs
  • Correct way of linking said variables to the input channels
  • Correct way of handling errors through the use of the 'Status' word

My attempt was the following for a 4-channel input thermocouple card:

  1. Create a global variable list for inputs and outputs named IO, then declare array of values and status for each thermocouple channel:
    // Term 4 EL3314: thermocouple signals
    TcInputValue_CH     AT %I* : ARRAY[1..4] OF INT;
    TcInputStatus_CH    AT %I* : ARRAY[1..4] OF WORD;
  1. Manually linking the variables. After compiling, both TcInputValue_CH and TcInputStatus_CH appear as PlcTask Inputs, so I can manually link them to the corresponding variable. It would be nice for bigger projects being able to use the attribute 'TcLinkTo', like this: {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.
  2. For handling errors, I first created appropriate variables to save the temperature of the thermocouple channel as well as the status of said channel. So that requires a struct:
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.


Solution

  • 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:

    enter image description here

    Once created, you can link entire input data to the structure like this: enter image description here

    As you can see, the entire input structure is created, you don't need to "think" about how to parse the status either: enter image description here

    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:

    enter image description here

    For the question about having AT%I* or AT%Q* declared inside a function block, here is how it appears in practice:

    enter image description here

    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. enter image description here

    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.