Three calendar types and what each stores
A TIME is a duration: 20 seconds of mixing, 3 minutes of drain. It does not know what day it is. Production does. A batch record needs the date and time the batch started; an alarm list needs the time each alarm came in; a shift report needs to know when 06:00 is; a clean-in-place skid needs to start at 02:30 every night. For that IEC 61131-3 gives you three calendar types, which chapter 2 introduced in passing:
| Type | Long name | Literal | Means |
|---|---|---|---|
DATE | DATE | D#2026-10-24 | a calendar day |
TOD | TIME_OF_DAY | TOD#06:00:00 or TOD#22:15:30.5 | a clock reading within a day |
DT | DATE_AND_TIME | DT#2026-10-24-06:00:00 | a point in time: day and clock reading together |
The literal forms are standard. What the controller stores behind them is not, and it matters the moment you send one of these values to another system or do arithmetic on it:
- CODESYS and TwinCAT store
DATEandDTas an unsigned 32-bit count of seconds since 1970-01-01 (for aDATEthe time part is zero), andTODas milliseconds since midnight. Unsigned 32-bit seconds run out in February 2106. The 64-bitLDATE,LTODandLDT, counting nanoseconds, cover the gap where your version supports them. - Siemens S7-1500 stores
DATEas a 16-bit count of days since 1990-01-01,TIME_OF_DAYas milliseconds since midnight, and the oldDATE_AND_TIMEas 8 bytes of BCD limited to 1990–2089. The type you actually use on an S7-1500 isDTL: a 12-byte structure withYEAR,MONTH,DAY,WEEKDAY,HOUR,MINUTE,SECONDandNANOSECONDmembers. There is alsoLDT, 64-bit nanoseconds since 1970. - Rockwell Logix has no calendar types at all. The clock comes out as a
DINT[7]array (year, month, day, hour, minute, second, microsecond) or as aLINTcount of microseconds since 1970 in UTC, and you handle it as integers.
So a DT copied byte for byte from a TwinCAT controller to an S7-1500 is garbage, and a Logix LINT timestamp means nothing to a CODESYS DT until someone divides by a million. When calendar values cross a boundary — to SCADA, MES, a historian or another PLC — send them in a format both sides define explicitly: Unix seconds in UTC as an integer, or an ISO 8601 string such as 2026-10-24T06:00:00Z. Chapter 29 comes back to interface design.
The declarations and literals work the same way everywhere, and the engine on this page runs them, along with comparisons and conversion to text:
PROGRAM Main
VAR
Now : TOD := TOD#09:41:20; (* input: type a time such as TOD#05:30:00 *)
ShiftStart : TOD := TOD#06:00:00;
BatchStart : DT := DT#2026-10-24-06:12:00;
BatchDate : DATE := D#2026-10-24;
AfterShiftStart : BOOL;
NowText : STRING;
END_VAR
AfterShiftStart := Now >= ShiftStart;
NowText := TOD_TO_STRING(Now);
END_PROGRAMType TOD#05:30:00 into Now and AfterShiftStart drops. Comparisons on calendar values work exactly as you would expect: later is greater. What this engine does not run is arithmetic on them — Now + T#2h is rejected at build time — so later sections show the IEC date functions as static listings and use plain integers to simulate a clock.
Reading the controller clock
Every PLC has a real-time clock, and every vendor reads it differently. None of these calls runs on this page; they are what you will type on the real platforms.
(* TIA Portal SCL, S7-1500 — static listing *)
VAR_TEMP
NowLocal : DTL;
NowUtc : DTL;
Ret : Int;
END_VAR
#Ret := RD_SYS_T(OUT => #NowUtc); (* system time = UTC *)
#Ret := RD_LOC_T(OUT => #NowLocal); (* local time: time zone and DST from the CPU properties *)
IF #NowLocal.HOUR = 6 AND #NowLocal.MINUTE = 0 THEN
; (* ... *)
END_IF;
On an S7-1500 the CPU keeps UTC internally. RD_SYS_T returns it; RD_LOC_T applies the time zone and daylight-saving rules you set in the CPU's hardware configuration. WR_SYS_T sets the clock, though on a networked plant you should let NTP do that — the S7-1500 can synchronise from up to four NTP servers.
(* Studio 5000 Logix ST — static listing *)
GSV(WallClockTime, , LocalDateTime, NowLocal[0]); (* DINT[7]: Y, M, D, h, m, s, µs *)
GSV(WallClockTime, , CurrentValue, NowUtc_us); (* LINT: µs since 1970, UTC *)
Logix reads system objects with GSV (Get System Value). The WallClockTime object holds UTC as CurrentValue, and local time as LocalDateTime once the time zone and DST settings are filled in. Time synchronisation on Logix normally comes from CIP Sync (IEEE 1588 PTP) over the backplane and EtherNet/IP.
In CODESYS the usual route is the SysTimeRtc system library: SysTimeRtcGet returns the clock as seconds since 1970 in UTC, and companion functions convert to local time and to a broken-down date. In TwinCAT 3 the PLC runs beside Windows, and the clock comes from there: F_GetSystemTime() returns a 64-bit UTC value in 100 ns ticks since 1601 (the Windows FILETIME format), and the FB_LocalSystemTime block in Tc2_Utilities delivers local time as a TIMESTRUCT and resynchronises it periodically. Function names and libraries shift between versions; check the library manager for the one installed.
Three facts apply on every platform:
- Reading the clock is not free on every platform. Some calls are cheap, some go through the operating system, and a few (like TwinCAT's
NT_GetTime) are asynchronous withSTARTandBUSYpins. The good pattern is the same everywhere: read the clock once per scan at the top of the program into a variable, and let everything else use that variable. Every timestamp in that scan then agrees. - The clock drifts. A typical RTC crystal is good to about ±20 ppm, which is 1.7 seconds a day or about 10 minutes a year. Without synchronisation, two controllers on the same line disagree by minutes within months, and a sequence-of-events report that interleaves their alarms is fiction.
- The clock jumps. NTP corrections, a technician setting the time from a laptop, a CPU battery that died over a long shutdown and restarted the clock in 1990 or 2012 — all of these happen. Code that compares clock readings must survive a reading that goes backwards.
The live examples below simulate the clock with a counter: a DINT holding seconds since midnight, advanced by a chosen amount each scan. That is not a workaround, it is a good habit. Converting the vendor clock into a plain "seconds of day" and "day number" once per scan gives the rest of the program simple integers to compare, and makes the logic testable without waiting for midnight.
FUNCTION TwoDigits : STRING
VAR_INPUT
N : DINT;
END_VAR
IF N < 10 THEN
TwoDigits := CONCAT('0', DINT_TO_STRING(N));
ELSE
TwoDigits := DINT_TO_STRING(N);
END_IF;
END_FUNCTION
PROGRAM Main
VAR
Rate_s : DINT := 600; (* input: simulated seconds per scan *)
SecOfDay : DINT := 82800; (* starts at 23:00:00 *)
DayNo : DINT := 1;
Hour : DINT;
Minute : DINT;
Clock : STRING;
END_VAR
SecOfDay := SecOfDay + Rate_s;
IF SecOfDay >= 86400 THEN
SecOfDay := SecOfDay - 86400; (* keep the remainder, not zero *)
DayNo := DayNo + 1;
END_IF;
Hour := SecOfDay / 3600;
Minute := (SecOfDay MOD 3600) / 60;
Clock := CONCAT(CONCAT(TwoDigits(Hour), ':'), TwoDigits(Minute));
END_PROGRAMEach scan stands for ten minutes, so midnight arrives after six scans: Clock goes from 23:50 to 00:00 and DayNo becomes 2. Change Rate_s to 1 for a clock that runs at ten times real speed. The rollover subtracts 86,400 instead of setting the counter to zero; with a rate that does not divide a day evenly, setting it to zero would lose a few seconds every midnight — the integer version of a clock that runs slow.
Time stamps for alarms and batch records
A time stamp answers "when did this happen?", and the answer must be the moment it started. That sounds obvious until you see how often alarm code gets it wrong. The stamp must be taken on the rising edge of the event, exactly once, and then left alone:
FUNCTION TwoDigits : STRING
VAR_INPUT
N : DINT;
END_VAR
IF N < 10 THEN
TwoDigits := CONCAT('0', DINT_TO_STRING(N));
ELSE
TwoDigits := DINT_TO_STRING(N);
END_IF;
END_FUNCTION
PROGRAM Main
VAR
HighTemp : BOOL; (* input: delayed alarm condition *)
SecOfDay : DINT := 50400; (* simulated clock, starts 14:00:00 *)
Clock : STRING;
AlarmEdge : R_TRIG;
AlarmAt : STRING; (* when the latest occurrence started *)
AlarmText : STRING;
Occurrences : DINT;
END_VAR
(* Simulated clock: one scan = one minute *)
SecOfDay := (SecOfDay + 60) MOD 86400;
Clock := CONCAT(CONCAT(TwoDigits(SecOfDay / 3600), ':'), TwoDigits((SecOfDay MOD 3600) / 60));
(* Stamp on the edge, once per occurrence *)
AlarmEdge(CLK := HighTemp);
IF AlarmEdge.Q THEN
AlarmAt := Clock;
AlarmText := CONCAT('Oven 2 high temperature since ', Clock);
Occurrences := Occurrences + 1;
END_IF;
END_PROGRAMSwitch HighTemp on: the alarm text records the minute it came in, and the clock carries on without dragging the stamp along. Switch it off and on again and you get a new stamp and a new occurrence. On a real controller the stamp would be a DT, LDT or DTL read from the clock at the top of the scan, and the text would be built on the HMI from the stored value — chapter 16 explains why text belongs there — but the rule is the same: stamp on the edge.
Batch records need a little more discipline than alarms, because they become quality documents. A good batch record in ST is a structure with a start stamp, an end stamp, the recipe and the totals, filled at defined points of the sequence and handed to the MES or historian when the batch closes:
(* Static listing — a batch record on an S7-1500; DTL is Siemens' date-time structure *)
TYPE ST_BatchRecord :
STRUCT
BatchNo : DINT;
RecipeNo : INT;
StartUtc : DTL; (* read with RD_SYS_T, stored in UTC *)
EndUtc : DTL;
Flour_kg : REAL;
Water_kg : REAL;
MixTime : TIME; (* measured with a timer, not by subtracting stamps *)
Complete : BOOL;
END_STRUCT
END_TYPE
Two details in that structure carry most of the experience. The stamps are UTC, for reasons the daylight-saving section makes clear. And the mix duration is measured with a timer and stored as a TIME, not computed by subtracting StartUtc from EndUtc; the stamps say when, the timer says how long, and if the clock was corrected during the batch only one of them is wrong.
Shifts, days and midnight
Most plants run on shifts that do not respect midnight. A three-shift pattern of 06:00–14:00, 14:00–22:00 and 22:00–06:00 puts the night shift on two calendar days. Code that works out "which shift is it now?" with clock comparisons meets the midnight problem immediately:
The second midnight problem is subtraction. A line stops at 23:50 and restarts at 00:20. In seconds of day that is 85,800 and 1,200, and 1200 - 85800 is −84,600: the stop lasted minus 23.5 hours. The fix is modular arithmetic: add a day before taking the remainder, (End - Start + 86400) MOD 86400, which gives 1,800 seconds, 30 minutes. It works for any stop shorter than a day. For longer stops you need the day number, or a full DT, or — better for anything that matters — a timer.
The third is the production day. A plant that reports by shift wants the pallet packed at 03:15 on 25 October booked to the night shift that started on 24 October. Most MES systems handle that by defining the production day as starting at 06:00 rather than at midnight. In ST it is one comparison: anything before the first shift starts belongs to the previous production day.
UTC, local time and daylight saving
Twice a year, most of Europe and North America change their clocks. In the EU the change happens at 01:00 UTC on the last Sunday of March (local clocks jump from 02:00 to 03:00) and the last Sunday of October (03:00 back to 02:00). In the US and Canada it is 02:00 local time on the second Sunday of March and the first Sunday of November. A plant that runs through the night lives through both.
In spring, the hour from 02:00 to 03:00 local does not exist. Anything scheduled at 02:30 local never sees 02:30. In autumn, the hour from 02:00 to 03:00 happens twice. A batch that starts at 02:40 local and ends at 02:10 local, both stamped in local time, appears to have taken minus 30 minutes. An alarm list sorted by local time puts a trip before its cause. These are not hypothetical; any site that stamps in local time has a few of them in its records.
The cure is a rule most experienced teams follow without discussion: keep UTC in the controller, show local time to people. UTC never jumps for daylight saving. Stamps stored in UTC sort correctly, subtract correctly and match the historian and MES, which almost always run in UTC as well. The HMI, which knows where it is, converts to local for display. The S7-1500 does this for you if you read RD_SYS_T; Logix keeps CurrentValue in UTC; CODESYS and TwinCAT give you both.
Local time still has a place: things that people schedule by the wall clock. A CIP that must run "at 02:30" runs at 02:30 local because that is when the night shift expects it. Converting is an offset plus a DST correction:
FUNCTION HhMm : STRING
VAR_INPUT
Sec : DINT;
END_VAR
VAR
H : DINT;
M : DINT;
Hs : STRING;
Ms : STRING;
END_VAR
H := Sec / 3600;
M := (Sec MOD 3600) / 60;
Hs := DINT_TO_STRING(H);
Ms := DINT_TO_STRING(M);
IF H < 10 THEN Hs := CONCAT('0', Hs); END_IF;
IF M < 10 THEN Ms := CONCAT('0', Ms); END_IF;
HhMm := CONCAT(CONCAT(Hs, ':'), Ms);
END_FUNCTION
PROGRAM Main
VAR
DstActive : BOOL; (* input: summer time in force *)
UtcSec : DINT := 79200; (* simulated UTC clock, starts 22:00 *)
UtcDay : DINT := 1;
Offset_s : DINT;
LocalSec : DINT;
LocalDay : DINT;
UtcClock : STRING;
LocalClock : STRING;
END_VAR
(* UTC: one scan = five minutes *)
UtcSec := UtcSec + 300;
IF UtcSec >= 86400 THEN
UtcSec := UtcSec - 86400;
UtcDay := UtcDay + 1;
END_IF;
(* Central European Time: UTC+1, or UTC+2 in summer *)
Offset_s := 3600;
IF DstActive THEN
Offset_s := 7200;
END_IF;
LocalSec := UtcSec + Offset_s;
LocalDay := UtcDay;
IF LocalSec >= 86400 THEN
LocalSec := LocalSec - 86400;
LocalDay := LocalDay + 1; (* local midnight comes before UTC midnight *)
END_IF;
UtcClock := HhMm(UtcSec);
LocalClock := HhMm(LocalSec);
END_PROGRAMRun it and watch local midnight arrive an hour before UTC midnight, with LocalDay moving on first. Flip DstActive and the local clock jumps an hour while the UTC clock carries on untouched — that jump is what every local-time stamp in the plant experiences twice a year. Real controllers take the DST rule from configuration (the S7-1500 CPU properties, the Logix WallClockTime settings, the operating system under CODESYS and TwinCAT), so you rarely write the rule yourself. You do write the logic that must survive it.
The IEC date functions, and what vendors do instead
IEC 61131-3 defines a family of functions for calendar arithmetic. Their names say exactly what they do:
| Function | Computes | Example |
|---|---|---|
ADD_TOD_TIME | TOD + TIME → TOD | 06:00 + 8 h → 14:00 |
ADD_DT_TIME | DT + TIME → DT | batch start + recipe time → expected end |
SUB_TOD_TIME, SUB_DT_TIME | subtract a duration | — |
SUB_TOD_TOD | TOD − TOD → TIME | 14:00 − 06:00 → 8 h |
SUB_DT_DT | DT − DT → TIME | end − start → duration |
SUB_DATE_DATE | DATE − DATE → TIME | days since last calibration |
CONCAT_DATE_TOD | DATE and TOD → DT | today's date with 06:00 → shift start |
DT_TO_DATE, DT_TO_TOD | split a DT | — |
(* IEC 61131-3 date functions — static listing, not run on this page *)
ShiftEnd := ADD_TOD_TIME(ShiftStart, T#8h);
BatchTime := SUB_DT_DT(BatchEnd, BatchStart);
ShiftStamp := CONCAT_DATE_TOD(DT_TO_DATE(NowDt), TOD#06:00:00);
NextCal := SUB_DATE_DATE(D#2027-04-01, DT_TO_DATE(NowDt));
CODESYS and TwinCAT accept these and, in most versions, also the plain + and - operators on calendar types. The 3rd edition adds SPLIT_DATE, SPLIT_TOD, SPLIT_DT and DAY_OF_WEEK, which are more useful in practice than most of the table. TIA Portal uses its own instructions instead: T_ADD and T_SUB add or subtract a duration from a DTL, DT or TOD, T_DIFF gives the difference of two as a TIME, and T_COMBINE joins a DATE and a TOD. On an S7-1500 you will more often just read the members of a DTL directly. Logix has none of this: you take two LINT microsecond values from WallClockTime and subtract them as integers.
Two cautions apply whichever form you use. A difference returned as a 32-bit TIME runs out at 24 or 49 days (signed or unsigned, by vendor, as chapter 16 explained), so "days since last calibration" overflows in under two months; use LTIME, or work in days. And a difference between two local-time stamps is wrong across a DST change by exactly one hour. Store UTC and the problem disappears.
LREAL: when seven digits run out
Chapter 2 showed the 32-bit REAL totaliser that stalls at 16,777,216 because the gap between neighbouring values grows past the increment. Clock values hit the same wall sooner than people expect. Unix time today is about 1.79 billion seconds. A 32-bit REAL near that value can only represent every 128th second, so a time stamp stored as REAL is wrong by up to a minute. Any value that combines a large magnitude with a small resolution — a totaliser, a time stamp, an encoder position on a long axis in micrometres — needs more than seven digits.
LREAL is the 64-bit IEEE 754 double: about 15 to 16 significant digits. That moves the stall point from 16.7 million to about 9,007,199,254,740,992 (2⁵³). It does not remove it. A note on this page's engine: it computes REAL in 64 bits too, so it cannot show you the 32-bit stall, but its LREAL behaves exactly like a PLC's:
The dependable totaliser pattern does not depend on the float type at all. Keep the whole units in an integer, and only the fraction — which never exceeds 1.0 — in a float. The float never gets large, so its seven digits are always enough:
PROGRAM Main
VAR
Flow_lps : REAL := 3.7; (* input: flow, litres per second *)
Whole_l : LINT; (* whole litres, exact forever *)
Frac_l : REAL; (* the part-litre not yet counted, 0..1 *)
Carry : DINT;
END_VAR
Frac_l := Frac_l + Flow_lps * 0.1; (* this scan's volume, 100 ms *)
Carry := TRUNC(Frac_l); (* whole litres in the fraction *)
Whole_l := Whole_l + Carry;
Frac_l := Frac_l - DINT_TO_REAL(Carry);
END_PROGRAMAt 3.7 L/s the count goes up by 0.37 L per scan; every third scan or so a whole litre moves from Frac_l into Whole_l. Change Flow_lps to 250 for a big filler and the carry becomes 25 per scan. The fraction shows tiny binary noise such as 0.1100000000000001 here (and 0.7000001 or similar on a 32-bit PLC); because it never grows, the noise never accumulates into the total. Flow meters and energy meters do essentially this internally, which is why their totals agree with the invoice and a float totaliser in a PLC usually does not.
Use LREAL for calculations that need the digits: an axis position in a 40 m gantry with micrometre resolution, a long-run mass balance, coordinate transforms. Remember its costs: 8 bytes per value, not every HMI or Modbus device can read it, and Logix supports it only on its newer controllers with a reduced instruction set. Do not make it the default for every analog value; a 4–20 mA transmitter has about four significant digits of real information, and REAL holds that with room to spare.
LINT and ULINT for energy totals
Energy is the totaliser that most often outgrows DINT. A medium-sized factory drawing 2 MW consumes 2,000,000 Wh every hour — 48 million Wh a day. A DINT counting watt-hours tops out at 2,147,483,647, which that site reaches in under 45 days. Then it wraps to −2,147,483,648, and the energy report shows the factory generating power. A UDINT lasts 89 days. A 64-bit LINT lasts about 500 million years.
This example runs one scan per minute of plant time and starts both counters a few hundred thousand watt-hours short of the DINT limit:
PROGRAM Main
VAR
Load_kW : REAL := 2000.0; (* input: site load *)
Step_Wh : DINT; (* energy in one simulated minute *)
Energy_Wh_DINT : DINT := 2147000000;
Energy_Wh_LINT : LINT := 2147000000;
Energy_MWh : LREAL; (* for display *)
END_VAR
Step_Wh := REAL_TO_DINT(Load_kW * 1000.0 / 60.0); (* kW for one minute, in Wh *)
Energy_Wh_DINT := Energy_Wh_DINT + Step_Wh;
Energy_Wh_LINT := Energy_Wh_LINT + Step_Wh;
Energy_MWh := LINT_TO_LREAL(Energy_Wh_LINT) / 1.0E6;
END_PROGRAMFifteen scans in, the DINT total turns negative and the LINT total carries on past 2.147 billion. Nothing faulted. Integer overflow in IEC 61131-3 wraps silently, as chapter 2 showed with INT, and a wrong energy figure goes straight into an invoice or a carbon report. Change Load_kW to 10,000 for a steelworks and the wrap comes five times sooner.
Choose the unit together with the type. Watt-hours in a LINT is exact and enormous; kilowatt-hours in a DINT lasts 122 years at 2 MW but cannot record a 0.4 kWh pump run. Count in the smallest unit the meter delivers (pulses usually represent 1 Wh, 10 Wh or 1 kWh), store it in LINT or ULINT, and convert for display. On an S7-1500, LInt and ULInt are available; the S7-1200 does not support 64-bit integers, so there the split pattern from the previous section (MWh in one DINT, the remainder in Wh in another) is the way. Older Logix controllers support LINT for only a handful of instructions, such as moves and the clock; the 5380 and 5580 families do arithmetic on it.
WSTRING, subranges, unions and aliases
Four more types appear in larger projects. You can go years without declaring one, but you need to read them.
WSTRING holds wide characters: two bytes each (UTF-16 or UCS-2, depending on the vendor), enough for Polish, Turkish, Greek, Chinese or Arabic operator texts. Literals use double quotes, "Ölpumpe 3", where a STRING uses single quotes. CODESYS and TwinCAT provide W-prefixed string functions such as WLEN and WCONCAT in their standard libraries; TIA Portal's WString (up to 16,382 characters) works with the ordinary LEN and CONCAT; Logix has no wide string at all. Converting with WSTRING_TO_STRING cannot keep characters outside the single-byte range; depending on the vendor they become a placeholder such as ? or are lost, so test with real texts.
(* Static listing: WSTRING length — not run here, this engine counts it wrongly *)
VAR
Label : WSTRING[40] := "Ölpumpe 3";
n : INT;
END_VAR
n := LEN(Label); (* 9 characters: Ö counts as one, though it takes 2 bytes *)
Opinion: keep multilingual text out of the controller. The HMI has text lists per language; the PLC sends a message number. A WSTRING in the PLC is justified when the controller itself must produce text for a printer, a label or a line in a batch report.
Subrange types restrict an integer to a range: TYPE T_Percent : INT(0..100); END_TYPE. CODESYS and TwinCAT reject an out-of-range constant at compile time, but at run time the range is only enforced if you add the implicit check functions CheckRangeSigned and CheckRangeUnsigned, the cousins of the CheckBounds function in chapter 27. TIA Portal and Studio 5000 have no subranges. Treat a subrange as documentation plus an optional run-time check, not as a guarantee, and still LIMIT values that come from outside.
(* CODESYS / TwinCAT — static listing *)
TYPE T_Percent : INT(0..100); END_TYPE
VAR
ValvePos : T_Percent := 50;
END_VAR
ValvePos := 150; (* compile error: constant out of range *)
ValvePos := HmiValue; (* compiles; only CheckRangeSigned catches it at run time *)
UNION lets several members share the same memory. Writing one member changes the others, because they are the same bytes viewed as different types. The classic use is sending a REAL over Modbus, which only knows 16-bit registers:
(* CODESYS / TwinCAT — static listing *)
TYPE U_Real32 :
UNION
r : REAL; (* 4 bytes as a float *)
w : ARRAY[0..1] OF WORD; (* the same 4 bytes as two registers *)
END_UNION
END_TYPE
VAR
Conv : U_Real32;
END_VAR
Conv.r := Flow_m3h;
Reg[100] := Conv.w[1]; (* word order: check the device manual *)
Reg[101] := Conv.w[0];
Which word goes first is the eternal Modbus argument; devices disagree, and the manual (or a test with a known value such as 1.0 = 16#3F80_0000) settles it. TIA Portal does the same job with an AT overlay declaration, with restrictions in optimized blocks; Logix uses COP to copy a REAL into an INT[2] byte for byte. This page's engine models a UNION as an ordinary structure whose members do not overlap, so overlay code cannot run here. Unions are type-punning: useful at a protocol boundary, a bug anywhere else.
Alias types give an existing type a new name: TYPE T_Pressure_bar : REAL; END_TYPE. They document intent — a parameter of type T_Pressure_bar says its unit in the declaration — and give you one place to change if a type must grow, such as a count that moves from DINT to LINT. They do not create a new type the compiler keeps apart. In CODESYS and TwinCAT an alias is fully compatible with its base type, so the compiler will happily add bar to rpm:
TYPE T_Pressure_bar : REAL; END_TYPE
TYPE T_Speed_rpm : REAL; END_TYPE
TYPE T_Count : DINT; END_TYPE
PROGRAM Main
VAR
Suction_bar : T_Pressure_bar := 1.8; (* input *)
Speed_rpm : T_Speed_rpm := 1480.0; (* input *)
Starts : T_Count;
Nonsense : REAL;
Label : WSTRING := "Ölpumpe 3";
StartEdge : R_TRIG;
END_VAR
StartEdge(CLK := Speed_rpm > 100.0);
IF StartEdge.Q THEN
Starts := Starts + 1;
END_IF;
(* Compiles without complaint: aliases do not keep units apart *)
Nonsense := Suction_bar + Speed_rpm;
END_PROGRAMNonsense reads 1481.8 — bar plus rpm. The alias did its documentation job and no checking job at all. If you want the compiler to keep quantities apart, the tool is a STRUCT with one member (a ST_Pressure cannot be added to a ST_Speed), at the cost of writing .Value everywhere. Most teams settle for aliases plus unit suffixes in names, which is what this course has used throughout. TIA Portal and Studio 5000 have no alias declaration; there the naming convention does the whole job.
From types to interfaces
Calendar types label events; timers measure delays. Read the controller clock once per scan, keep it in UTC, and convert to local only for people and wall-clock schedules. Every "between" test on a clock must decide what happens at midnight, every subtraction must survive a wrap, and every daily job must be idempotent, so that a skipped hour or a repeated hour does no harm. For totals, count in integers wide enough for the plant's lifetime, keep floats small, and know where each float type stops absorbing increments. WSTRING, subranges, unions and aliases each do one job well and cause trouble when stretched beyond it.
Several of those lessons already pointed outward: time stamps that MES must read, UNIONs that exist to talk Modbus, text that belongs on the HMI. The next chapter is about that boundary: command and status words, heartbeats and handshakes, so that an HMI, a SCADA system or another PLC can talk to your program without either side guessing.