Data Logging
PLC Data Logging & Historian Integration: CSV, Ring Buffers & Time-Series in Structured Text
Build production-grade data logging directly in your PLC — ring buffers, timestamped CSV records, event-triggered snapshots, and historian integration patterns in Structured Text.
Why log data in the PLC
Every production system needs data for process optimization, quality traceability, regulatory compliance and troubleshooting. SCADA historians (such as Wonderware, OSIsoft PI or InfluxDB) handle the long-term storage, but the PLC is where the data is first captured.
Logging at the PLC level has four advantages. The PLC scans at 1–10ms while SCADA polls every 100ms to 1s, so a SCADA-only log misses whatever happens between polls. Logging carries on when SCADA is down. You can log on events, when something interesting happens, as well as on a timer. And the PLC can calculate averages, min/max values and rates before the data goes to the historian.
The ring buffer at the core
A ring buffer (circular buffer) is the foundation of PLC data logging. When it is full it overwrites the oldest record, so the log never grows past the memory you gave it:
TYPE LogRecord :
STRUCT
Timestamp : STRING[20]; // 'YYYY-MM-DD HH:MM:SS'
Tag1_Temp : REAL;
Tag2_Press : REAL;
Tag3_Flow : REAL;
Tag4_Level : REAL;
EventCode : INT; // 0=periodic, >0=event type
Quality : BYTE; // 0=Good, 1=Uncertain, 2=Bad
END_STRUCT;
END_TYPE
FUNCTION_BLOCK FB_RingBuffer
VAR
Buffer : ARRAY[0..999] OF LogRecord; // 1000 records
WriteIndex : INT := 0;
ReadIndex : INT := 0;
RecordCount : DINT := 0;
BufferFull : BOOL := FALSE;
Capacity : INT := 1000;
END_VAR
// Write a new record
// Call this method to add data to the buffer
// Add a record to the ring buffer
FUNCTION_BLOCK FB_RingBuffer
// ... (VAR section from above)
VAR_INPUT
WriteEnable : BOOL;
NewRecord : LogRecord;
END_VAR
VAR_OUTPUT
RecordsStored: DINT;
IsFull : BOOL;
OldestOverwritten : BOOL;
END_VAR
IF WriteEnable THEN
Buffer[WriteIndex] := NewRecord;
WriteIndex := WriteIndex + 1;
IF WriteIndex >= Capacity THEN
WriteIndex := 0;
BufferFull := TRUE;
END_IF;
RecordCount := RecordCount + 1;
OldestOverwritten := BufferFull;
IF BufferFull THEN
RecordsStored := INT_TO_DINT(Capacity);
ELSE
RecordsStored := INT_TO_DINT(WriteIndex);
END_IF;
IsFull := BufferFull;
END_IF;
Sizing the buffer
| Records | Fields per Record | Approx. Memory |
|---|---|---|
| 1,000 | 6 REAL + metadata | ~40 KB |
| 5,000 | 6 REAL + metadata | ~200 KB |
| 10,000 | 6 REAL + metadata | ~400 KB |
| 50,000 | 6 REAL + metadata | ~2 MB |
Periodic logging with down-sampling
You don't need to log every scan. A configurable interval, with optional averaging, cuts the data volume dramatically:
FUNCTION_BLOCK FB_PeriodicLogger
VAR_INPUT
Enable : BOOL;
LogInterval : TIME := T#1S; // How often to write a record
PV_Temp : REAL;
PV_Pressure : REAL;
PV_Flow : REAL;
PV_Level : REAL;
END_VAR
VAR_OUTPUT
TriggerLog : BOOL; // Pulses TRUE when a record should be written
AvgRecord : LogRecord; // Averaged data for this interval
END_VAR
VAR
tmrInterval : TON;
SampleCount : INT := 0;
SumTemp : REAL := 0.0;
SumPressure : REAL := 0.0;
SumFlow : REAL := 0.0;
SumLevel : REAL := 0.0;
MinTemp : REAL := 99999.0;
MaxTemp : REAL := -99999.0;
END_VAR
TriggerLog := FALSE;
IF NOT Enable THEN
SampleCount := 0;
SumTemp := 0.0;
SumPressure := 0.0;
SumFlow := 0.0;
SumLevel := 0.0;
RETURN;
END_IF;
// Accumulate samples every scan
SampleCount := SampleCount + 1;
SumTemp := SumTemp + PV_Temp;
SumPressure := SumPressure + PV_Pressure;
SumFlow := SumFlow + PV_Flow;
SumLevel := SumLevel + PV_Level;
IF PV_Temp < MinTemp THEN MinTemp := PV_Temp; END_IF;
IF PV_Temp > MaxTemp THEN MaxTemp := PV_Temp; END_IF;
// On interval, compute averages and trigger log
tmrInterval(IN := TRUE, PT := LogInterval);
IF tmrInterval.Q THEN
tmrInterval(IN := FALSE);
IF SampleCount > 0 THEN
AvgRecord.Tag1_Temp := SumTemp / INT_TO_REAL(SampleCount);
AvgRecord.Tag2_Press := SumPressure / INT_TO_REAL(SampleCount);
AvgRecord.Tag3_Flow := SumFlow / INT_TO_REAL(SampleCount);
AvgRecord.Tag4_Level := SumLevel / INT_TO_REAL(SampleCount);
AvgRecord.EventCode := 0; // Periodic sample
AvgRecord.Quality := 0; // Good
END_IF;
TriggerLog := TRUE;
// Reset accumulators
SampleCount := 0;
SumTemp := 0.0;
SumPressure := 0.0;
SumFlow := 0.0;
SumLevel := 0.0;
MinTemp := 99999.0;
MaxTemp := -99999.0;
END_IF;
Event-driven logging
Periodic logging misses transient events. An event-driven logger captures data at the moment something interesting happens:
FUNCTION_BLOCK FB_EventLogger
VAR_INPUT
Enable : BOOL;
// Process values
PV_Temp : REAL;
PV_Pressure : REAL;
PV_Flow : REAL;
// Event triggers
AlarmActive : BOOL;
StateChanged : BOOL;
SetpointChanged : BOOL;
// Deadband for change-of-value logging
TempDeadband : REAL := 0.5;
PressDeadband : REAL := 0.1;
END_VAR
VAR_OUTPUT
TriggerLog : BOOL;
EventRecord : LogRecord;
EventType : INT;
END_VAR
VAR
prevTemp : REAL;
prevPressure : REAL;
prevAlarm : BOOL;
END_VAR
TriggerLog := FALSE;
EventType := 0;
IF NOT Enable THEN RETURN; END_IF;
// Event 1: Alarm state change
IF AlarmActive <> prevAlarm THEN
TriggerLog := TRUE;
EventType := 1; // Alarm event
EventRecord.EventCode := 1;
END_IF;
prevAlarm := AlarmActive;
// Event 2: Process state change
IF StateChanged THEN
TriggerLog := TRUE;
EventType := 2; // State change
EventRecord.EventCode := 2;
END_IF;
// Event 3: Significant change of value (deadband)
IF ABS(PV_Temp - prevTemp) > TempDeadband THEN
TriggerLog := TRUE;
EventType := 3; // COV temperature
EventRecord.EventCode := 3;
prevTemp := PV_Temp;
END_IF;
IF ABS(PV_Pressure - prevPressure) > PressDeadband THEN
TriggerLog := TRUE;
EventType := 4; // COV pressure
EventRecord.EventCode := 4;
prevPressure := PV_Pressure;
END_IF;
// Event 4: Setpoint change
IF SetpointChanged THEN
TriggerLog := TRUE;
EventType := 5; // Setpoint change
EventRecord.EventCode := 5;
END_IF;
// Fill record with current values
IF TriggerLog THEN
EventRecord.Tag1_Temp := PV_Temp;
EventRecord.Tag2_Press := PV_Pressure;
EventRecord.Tag3_Flow := PV_Flow;
EventRecord.Quality := 0;
END_IF;
CSV formatting for file export
When you log to a file (on an SD card, an FTP server or a network share), use CSV. Every analysis tool can open it:
FUNCTION FC_RecordToCSV : STRING[255]
VAR_INPUT
Record : LogRecord;
END_VAR
VAR
sTemp : STRING[12];
sPress : STRING[12];
sFlow : STRING[12];
sLevel : STRING[12];
sEvent : STRING[6];
sQual : STRING[4];
END_VAR
// Convert REALs to strings with 2 decimal places
sTemp := REAL_TO_STRING(Record.Tag1_Temp);
sPress := REAL_TO_STRING(Record.Tag2_Press);
sFlow := REAL_TO_STRING(Record.Tag3_Flow);
sLevel := REAL_TO_STRING(Record.Tag4_Level);
sEvent := INT_TO_STRING(Record.EventCode);
sQual := BYTE_TO_STRING(Record.Quality);
// Build CSV line
FC_RecordToCSV := CONCAT(
CONCAT(Record.Timestamp, ','),
CONCAT(sTemp, ','),
CONCAT(sPress, ','),
CONCAT(sFlow, ','),
CONCAT(sLevel, ','),
CONCAT(sEvent, ','),
sQual
);
// CSV header — write this as the first line of each file
FUNCTION FC_CSVHeader : STRING[255]
FC_CSVHeader := 'Timestamp,Temperature_C,Pressure_bar,Flow_Lmin,Level_pct,EventCode,Quality';
Rotating the log files
A single file that grows forever soon becomes unmanageable. Rotate to a new file daily or when the file reaches a set size:
FUNCTION_BLOCK FB_FileRotation
VAR_INPUT
CurrentDate : STRING[10]; // 'YYYY-MM-DD'
MaxRecordsPerFile : DINT := 86400; // ~1 day at 1s intervals
END_VAR
VAR_OUTPUT
FileName : STRING[64];
NewFileNeeded : BOOL;
END_VAR
VAR
lastDate : STRING[10];
recordsInFile : DINT := 0;
END_VAR
// Check if date changed or file is full
IF CurrentDate <> lastDate OR recordsInFile >= MaxRecordsPerFile THEN
NewFileNeeded := TRUE;
lastDate := CurrentDate;
recordsInFile := 0;
// Generate filename: Log_2026-03-08.csv
FileName := CONCAT('Log_', CONCAT(CurrentDate, '.csv'));
ELSE
NewFileNeeded := FALSE;
END_IF;
recordsInFile := recordsInFile + 1;
Getting the data into the historian
Pattern 1: buffered upload
Collect data in the PLC ring buffer, then upload it in batches to reduce network overhead:
FUNCTION_BLOCK FB_HistorianUploader
VAR_INPUT
Enable : BOOL;
UploadTrigger : BOOL; // External trigger or timer-based
BufferCount : DINT; // Records available in ring buffer
END_VAR
VAR_OUTPUT
Uploading : BOOL;
RecordsSent : DINT;
UploadError : BOOL;
LastUploadTime: STRING[20];
END_VAR
VAR
State : INT := 0;
BatchSize : INT := 100; // Records per upload batch
BatchIndex : INT := 0;
tmrTimeout : TON;
END_VAR
CASE State OF
0: // IDLE — wait for trigger
Uploading := FALSE;
IF Enable AND UploadTrigger AND BufferCount > 0 THEN
BatchIndex := 0;
RecordsSent := 0;
State := 1;
END_IF;
1: // SENDING BATCH
Uploading := TRUE;
// Platform-specific: write BatchSize records to historian
// via OPC UA, Modbus, REST API, or proprietary protocol
// Simulate: mark records as sent
RecordsSent := RecordsSent + INT_TO_DINT(BatchSize);
BatchIndex := BatchIndex + 1;
IF RecordsSent >= BufferCount THEN
State := 2; // All sent
ELSE
State := 1; // More batches to send
END_IF;
2: // COMPLETE
Uploading := FALSE;
UploadError := FALSE;
State := 0;
END_CASE;
Pattern 2: store-and-forward
When the historian connection is unreliable, store the data locally and forward it once the connection is back:
FUNCTION_BLOCK FB_StoreAndForward
VAR_INPUT
NewRecord : LogRecord;
WriteEnable : BOOL;
HistorianOnline : BOOL;
END_VAR
VAR_OUTPUT
LocalRecords : DINT;
PendingUpload : DINT;
StorageUsedPct : REAL;
END_VAR
VAR
localBuffer : FB_RingBuffer;
uploader : FB_HistorianUploader;
forwardReady : BOOL;
END_VAR
// Always write to local buffer
localBuffer(WriteEnable := WriteEnable, NewRecord := NewRecord);
LocalRecords := localBuffer.RecordsStored;
StorageUsedPct := (DINT_TO_REAL(LocalRecords) / 1000.0) * 100.0;
// Forward to historian when online
forwardReady := HistorianOnline AND LocalRecords > 0;
uploader(
Enable := TRUE,
UploadTrigger := forwardReady,
BufferCount := LocalRecords
);
PendingUpload := LocalRecords - uploader.RecordsSent;
Shift reports from the logged data
Aggregate the logged data into shift summaries for your production KPIs:
TYPE ShiftReport :
STRUCT
ShiftStart : STRING[20];
ShiftEnd : STRING[20];
AvgTemp : REAL;
MinTemp : REAL;
MaxTemp : REAL;
AvgPressure : REAL;
TotalFlow : REAL; // Accumulated flow
RunTimeMinutes : REAL;
DownTimeMinutes: REAL;
AlarmCount : INT;
BatchesComplete: INT;
OEE_Pct : REAL; // Overall Equipment Effectiveness
END_STRUCT;
END_TYPE
FUNCTION_BLOCK FB_ShiftReporter
VAR_INPUT
Enable : BOOL;
ShiftActive : BOOL;
PV_Temp : REAL;
PV_Pressure : REAL;
PV_Flow : REAL;
MachineRunning : BOOL;
AlarmActive : BOOL;
BatchDone : BOOL;
END_VAR
VAR_OUTPUT
CurrentShift : ShiftReport;
ShiftComplete : BOOL;
END_VAR
VAR
SampleCount : DINT := 0;
SumTemp : REAL := 0.0;
SumPressure : REAL := 0.0;
prevAlarm : BOOL;
prevBatch : BOOL;
scanTimeS : REAL := 0.01; // 10ms scan
END_VAR
IF NOT Enable OR NOT ShiftActive THEN
IF ShiftActive = FALSE AND SampleCount > 0 THEN
// Shift just ended — finalize report
CurrentShift.AvgTemp := SumTemp / DINT_TO_REAL(SampleCount);
CurrentShift.AvgPressure := SumPressure / DINT_TO_REAL(SampleCount);
// Calculate OEE (simplified)
IF (CurrentShift.RunTimeMinutes + CurrentShift.DownTimeMinutes) > 0.0 THEN
CurrentShift.OEE_Pct := (CurrentShift.RunTimeMinutes /
(CurrentShift.RunTimeMinutes + CurrentShift.DownTimeMinutes)) * 100.0;
END_IF;
ShiftComplete := TRUE;
END_IF;
RETURN;
END_IF;
ShiftComplete := FALSE;
SampleCount := SampleCount + 1;
// Accumulate
SumTemp := SumTemp + PV_Temp;
SumPressure := SumPressure + PV_Pressure;
CurrentShift.TotalFlow := CurrentShift.TotalFlow + (PV_Flow * scanTimeS / 60.0); // L/min to L
// Min/Max tracking
IF PV_Temp < CurrentShift.MinTemp OR SampleCount = 1 THEN
CurrentShift.MinTemp := PV_Temp;
END_IF;
IF PV_Temp > CurrentShift.MaxTemp OR SampleCount = 1 THEN
CurrentShift.MaxTemp := PV_Temp;
END_IF;
// Runtime tracking
IF MachineRunning THEN
CurrentShift.RunTimeMinutes := CurrentShift.RunTimeMinutes + (scanTimeS / 60.0);
ELSE
CurrentShift.DownTimeMinutes := CurrentShift.DownTimeMinutes + (scanTimeS / 60.0);
END_IF;
// Count alarms (rising edge)
IF AlarmActive AND NOT prevAlarm THEN
CurrentShift.AlarmCount := CurrentShift.AlarmCount + 1;
END_IF;
prevAlarm := AlarmActive;
// Count batches (rising edge)
IF BatchDone AND NOT prevBatch THEN
CurrentShift.BatchesComplete := CurrentShift.BatchesComplete + 1;
END_IF;
prevBatch := BatchDone;
Compression with the swinging door algorithm
For long-term storage, the Swinging Door Trending (SDT) algorithm compresses time-series data by recording only the points where the trend changes direction by more than a tolerance:
FUNCTION_BLOCK FB_SwingingDoor
VAR_INPUT
Enable : BOOL;
InputValue : REAL;
Tolerance : REAL := 0.5; // Compression deadband
MaxHoldTime : TIME := T#60S; // Force a point at least this often
END_VAR
VAR_OUTPUT
StorePoint : BOOL; // TRUE = this point should be stored
OutputValue : REAL;
CompressionRatio : REAL;
END_VAR
VAR
lastStoredVal : REAL;
lastStoredTime: DINT; // Scan counter as time proxy
slopeHigh : REAL;
slopeLow : REAL;
scanCounter : DINT := 0;
totalPoints : DINT := 0;
storedPoints : DINT := 0;
tmrForce : TON;
END_VAR
StorePoint := FALSE;
scanCounter := scanCounter + 1;
totalPoints := totalPoints + 1;
IF NOT Enable THEN RETURN; END_IF;
// Force store on timeout
tmrForce(IN := TRUE, PT := MaxHoldTime);
IF scanCounter = 1 THEN
// First point — always store
StorePoint := TRUE;
lastStoredVal := InputValue;
lastStoredTime := scanCounter;
storedPoints := storedPoints + 1;
tmrForce(IN := FALSE);
RETURN;
END_IF;
// Calculate slope window
slopeHigh := (lastStoredVal + Tolerance - InputValue) / DINT_TO_REAL(scanCounter - lastStoredTime);
slopeLow := (lastStoredVal - Tolerance - InputValue) / DINT_TO_REAL(scanCounter - lastStoredTime);
// If value breaks out of the slope window, or timer expires
IF InputValue > (lastStoredVal + Tolerance)
OR InputValue < (lastStoredVal - Tolerance)
OR tmrForce.Q THEN
StorePoint := TRUE;
OutputValue := InputValue;
lastStoredVal := InputValue;
lastStoredTime := scanCounter;
storedPoints := storedPoints + 1;
tmrForce(IN := FALSE);
END_IF;
// Compression ratio
IF totalPoints > 0 THEN
CompressionRatio := (1.0 - (DINT_TO_REAL(storedPoints) / DINT_TO_REAL(totalPoints))) * 100.0;
END_IF;
On slowly changing process data, SDT typically achieves 85–95% compression and still keeps every significant transition. OSIsoft PI and other enterprise historians use the same algorithm.
Which pattern does what
| Pattern | Purpose |
|---|---|
| Ring buffer | Fixed-size circular storage; never runs out of memory |
| Periodic logging | Timer-based with averaging for steady-state data |
| Event-driven logging | Capture alarms, state changes, and COV events |
| CSV formatting | Universal export format for analysis tools |
| File rotation | Daily or size-based file management |
| Store-and-forward | Buffer locally, upload when historian is available |
| Shift reports | Aggregate KPIs per production shift |
| Swinging Door | Compress time-series data 85–95% while preserving trends |