Terug naar publicaties

Engineering principles: Separation of Concerns en interfaces in SCL

Separation of Concerns in SCL: een UDT als datacontract als praktisch alternatief voor interfaces, en waarom eerlijkheid over de beperkingen van TIA Portal belangrijker is dan de gelijkenis mooier voor te stellen.

Engineering principles: Separation of Concerns en interfaces in SCL

Intro

Wie uit de software-wereld komt en overstapt naar PLC-programmeren, herkent een bepaald soort blok meteen: één Function Block dat "alles" doet. Start, stop, foutafhandeling, drie verschillende motorvarianten, allemaal in dezelfde FB, aangestuurd door een Mode-ingang die via een CASE-statement bepaalt welk stuk logica die scan uitvoert.

In C# zou ik dit nooit zo bouwen. Ik zou het opsplitsen en laten afhangen van een interface in plaats van één logge klasse met een switch erin. De vraag is: werkt die reflex ook in SCL? Deels. En het eerlijke antwoord op waar het wél en niet werkt, is nuttiger dan doen alsof TIA Portal stiekem C# is.

Dit artikel focust op PLC, maar wie benieuwd is hoe dit er in C# zelf uitziet, vindt onderaan een concreet voorbeeld. Voel je vrij om dat over te slaan als je liever meteen bij PLC blijft.


Het herkenbare probleem

Een FB die met een Mode-ingang tussen totaal verschillende gedragingen schakelt, ziet er ongeveer zo uit:

FUNCTION_BLOCK "FB_MotorUniverseel"
VAR_INPUT
    Mode        : INT;  // 1=Standaard, 2=VFD, 3=Softstarter
    Start       : BOOL;
    Stop        : BOOL;
END_VAR
VAR_OUTPUT
    Running     : BOOL;
    Fault       : BOOL;
END_VAR
BEGIN
    CASE #Mode OF
        1: // standaard motorlogica
           ...
        2: // VFD-specifieke logica, andere telegrammen, andere timing
           ...
        3: // softstarter-logica, weer andere aansturing
           ...
    END_CASE;
END_FUNCTION_BLOCK

Werkt prima tot iemand een vierde motortype moet toevoegen, of tot twee collega's tegelijk in dezelfde FB zitten te wijzigen. De drie gedragingen hebben niets met elkaar te maken behalve dat ze toevallig in hetzelfde blok wonen.


Separation of concerns

Separation of concerns betekent dat elk stuk code één duidelijke verantwoordelijkheid heeft, en niets weet van de interne details van de rest. In C# bereik ik dat door de aansturing te ontkoppelen van de implementatie: aanroepende code werkt tegen een interface en weet niet, en hoeft niet te weten, welke concrete klasse erachter zit. Niet "kleinere klassen" is de kern, maar een vast contract waar meerdere implementaties achter kunnen zitten zonder dat de aanroeper iets merkt.


Waarom de FB/instance-DB vergelijking maar half opgaat

Dat "deels" uit de inleiding zit hem precies hier. De voor de hand liggende vergelijking is: FB = klasse, instance-DB = object-state. Dat klopt voor een deel — een FB met zijn eigen instance-DB bundelt gedrag en toestand net als een object dat doet. Maar de vergelijking stopt daar. Klassieke SCL (zonder de nieuwere OOP-uitbreidingen die Siemens voor specifieke S7-1500-firmwareversies heeft toegevoegd) kent geen interface-sleutelwoord. Er is geen compiler die afdwingt dat FB_MotorStandaard en FB_MotorVFD dezelfde "vorm" hebben. Niets houdt een collega tegen om per ongeluk een extra parameter toe te voegen aan de ene implementatie en niet aan de andere.

Dat is geen tekortkoming om te verzwijgen — het is het uitgangspunt waarmee je moet ontwerpen.


De UDT als contract

Wat wél werkt, en wat het praktische equivalent van een interface wordt: een vast datacontract tussen twee blokken. In de praktijk bouw ik dat als één UDT, I_ControllerMotor — de I_-prefix maakt meteen duidelijk dat dit een interface is, geen gewone databuffer. Binnenin splits ik de data wél op in twee geneste structuren: FromController voor wat de aansturende laag naar het toestel stuurt, en FromMotor voor wat het toestel terugmeldt. Zo moet je maar één type linken in elke FB, terwijl de richting van elk veld toch meteen duidelijk blijft uit de naam.

TYPE "I_ControllerMotor"
STRUCT
    FromController : STRUCT
        CmdStart : BOOL;
        CmdStop  : BOOL;
        CmdSpeed : REAL;
    END_STRUCT;
    FromMotor : STRUCT
        Starting  : BOOL;
        Running   : BOOL;
        Stopping  : BOOL;
        Fault     : BOOL;
        FaultCode : INT;
    END_STRUCT;
END_STRUCT
END_TYPE

Elke motorvariant krijgt zijn eigen FB, maar allemaal met exact hetzelfde I_ControllerMotor-type als VAR_IN_OUT:

FUNCTION_BLOCK "FB_MotorStandaard"
VAR_IN_OUT
    IO : "I_ControllerMotor";
END_VAR
BEGIN
    IF #IO.FromController.CmdStart AND NOT #IO.FromMotor.Running THEN
        #IO.FromMotor.Starting := TRUE;
        // standaard-startlogica hier
        #IO.FromController.CmdStart := FALSE; // commando afgehandeld
    END_IF;
END_FUNCTION_BLOCK

FUNCTION_BLOCK "FB_MotorVFD"
VAR_IN_OUT
    IO : "I_ControllerMotor";
END_VAR
BEGIN
    IF #IO.FromController.CmdStart AND NOT #IO.FromMotor.Running THEN
        #IO.FromMotor.Starting := TRUE;
        // VFD-specifieke startlogica hier
        #IO.FromController.CmdStart := FALSE;
    END_IF;
END_FUNCTION_BLOCK

Merk op dat #IO.FromController.CmdStart na acceptatie zelf op FALSE wordt gezet — door het blok dat het commando ontvangt, niet door de aanroeper. Dat is bewust: de aansturende laag zet een commando, en het toestel-blok bevestigt door het terug te resetten. Een soort handshake tussen de twee blokken, mogelijk gemaakt doordat I_ControllerMotor als VAR_IN_OUT wordt doorgegeven — wijzigingen binnen de FB werken dan door naar de instantie die de aanroeper zelf gebruikt.

Het voordeel zie je vooral als er iets misloopt: staat CmdStart nog op TRUE terwijl de motor niet draait, dan weet je meteen dat het blok het commando nog niet heeft geaccepteerd — in plaats van dat je door de code moet om te zien of een enkele-scan-edge al dan niet gemist is.

De aanroepende sequentielogica kent alleen I_ControllerMotor. Het maakt voor die laag niet uit welke FB er achter een gegeven motor-instantie zit — dezelfde discipline die een interface in C# afdwingt, hier bereikt via één gedeelde datastructuur in plaats van een taalconstruct.

Het verschil met C# blijft reëel: in C# waarschuwt de compiler me als een klasse de interface niet volledig implementeert. Hier is het een afspraak binnen het team, gecontroleerd via code review en projectstandaarden, niet door de compiler. Wie dit patroon overneemt, moet dat verschil kennen — anders verkoop je zekerheid die er niet is.


Waar dit zich uitbetaalt

Dit patroon loont vooral wanneer een machinepark meerdere varianten van hetzelfde type apparaat gebruikt — bijvoorbeeld verschillende motoraandrijvingen op verschillende lijnen — of wanneer meerdere engineers tegelijk aan dezelfde bibliotheek werken. Nieuwe motorvariant toevoegen betekent een nieuwe FB tegen hetzelfde contract, zonder bestaande sequentielogica aan te raken. Een nieuwe collega die de sequentielaag moet aanpassen, hoeft alleen het I_ControllerMotor-type te kennen, niet de interne logica van elke motorvariant.

Bij een project voor een klant met een eigen technisch team kwam dit letterlijk zo terug. Er waren twee toestellen die in essentie hetzelfde deden, maar met een groot verschil in complexiteit: het ene was een eenvoudig toestel met weinig bewegende delen en een beperkte communicatie-interface die over RS232 sprak met een specifiek protocol. Het andere toestel, high-end met robotarmen, had een veel gecompliceerdere communicatie-interface over Profinet. Beide hadden andere parameters nodig om hun werk te doen.

Ikzelf en het team van de klant, dat deze code na mijn opdracht zelf zou beheren, eindigden in de PLC met een blokflow gebaseerd op een gedeelde interface. Elk toestel kreeg zijn eigen specifieke blok dat alle toestelspecifieke zaken afhandelde, net zoals hierboven bij de verschillende motoren. De interface was de abstractielaag: die zorgde ervoor dat de andere blokken die info van deze toestellen nodig hadden, op dezelfde manier konden werken. Die structuur was hier het contract, en de code lag klaar voor een eventuele wijziging van toestel. Zou er ooit een derde toestel bijkomen, dan moet er een blok specifiek voor dat toestel geschreven worden om de communicatie af te handelen. Zolang dat blok dezelfde interface ondersteunt, is het een drop-in replacement.

Door dezelfde data flow te gebruiken voor beide toestellen, hoefden we maar één datastroom te onderhouden. Data-integriteit is daarbij cruciaal: je wil niet meerdere verschillende data flows onderhouden puur omdat het toestel anders is. Zo verminder je het aantal bewegende onderdelen, en kun je een fout makkelijk isoleren tot het juiste blok, mocht die optreden. Separation of concerns helpt hier enorm, door op voorhand vast te leggen welk blok wat doet en de verantwoordelijkheden af te bakenen voordat het programmeren echt begint. Het team van de klant beheert deze code nu zelfstandig, zonder dat ze de interne logica van elk toestel opnieuw moesten leren — precies het voordeel dat hierboven al werd aangehaald.


Het C#-equivalent

Een klassiek voorbeeld: een service die met een databank moet praten.

public interface IUserRepository
{
    User? GetById(int id);
    void Save(User user);
}

public class PostgresUserRepository : IUserRepository
{
    // praat effectief met de databank
    public User? GetById(int id) { /* query uitvoeren */ return null; }
    public void Save(User user) { /* rij wegschrijven */ }
}

public class InMemoryUserRepository : IUserRepository
{
    // voor unit tests, geen echte databank nodig
    private readonly Dictionary<int, User> _users = new();
    public User? GetById(int id) => _users.GetValueOrDefault(id);
    public void Save(User user) => _users[user.Id] = user;
}

public class UserService
{
    private readonly IUserRepository _repository;

    public UserService(IUserRepository repository)
    {
        _repository = repository;
    }

    public void Register(User user) => _repository.Save(user);
}

UserService kent alleen IUserRepository. Of _repository uiteindelijk met Postgres praat of gewoon een dictionary in het geheugen is, maakt voor die klasse niets uit — en dat is precies waarom je zonder echte databank kunt unit-testen, of achteraf van databank kunt wisselen zonder UserService aan te raken.

De I-prefix (IUserRepository) is in C# een naamgevingsconventie, geen taalkundige vereiste — de compiler dwingt niets af, maar elke .NET-ontwikkelaar herkent het meteen als "dit is een contract". Om die reden gebruik ik in TIA Portal bewust dezelfde I_-prefix voor UDT's zoals I_ControllerMotor hierboven: geen toeval, gewoon dezelfde gewoonte toegepast op een andere taal.


Conclusie

SCL geeft geen interfaces in de taalkundige zin — die eerlijkheid is belangrijker dan de gelijkenis mooier voor te stellen dan ze is. Wat je wel hebt, is een UDT die je bewust als contract kunt inzetten, gecombineerd met de discipline om je FB's daar consequent tegen te bouwen. Voor wie uit de softwarewereld komt, is dat een vertrouwde manier van denken toegepast op een omgeving die minder afdwingt en meer vraagt van de programmeur zelf.

Wil je meer weten over deze aanpak of hoe wij structuur aanbrengen in een PLC-project? Neem gerust contact met ons op.


Vragen over uw automatiseringsproject?

Kom in contact met een expert