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. While SCADA historians (like Wonderware, OSIsoft PI, or InfluxDB) handle long-term storage, the PLC is the first line of data capture.
Key reasons to implement logging at the PLC level:
Ring Buffer: The Core Data Structure
A ring buffer (circular buffer) is the foundation of PLC data logging. It overwrites the oldest data when full, ensuring the PLC never runs out of memory:
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_TYPEFUNCTION_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;
Memory Sizing
| 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 |
Most modern PLCs (S7-1500, CompactLogix, Beckhoff CX) have 2–32 MB of retain memory. Size your buffer based on available memory and required retention time.
Periodic Logging with Down-Sampling
You don't need to log every scan. A configurable interval with optional averaging reduces 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_VARTriggerLog := 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. Event-driven logging captures data when 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_VARTriggerLog := 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 logging to a file (SD card, FTP server, or network share), CSV format is universally compatible:
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';
File Rotation Strategy
Don't write to a single file forever — it becomes unmanageable. Rotate files daily or by 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;
Historian Integration Patterns
Pattern 1: Buffered Upload
Collect data in the PLC ring buffer, then upload 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_VARCASE 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 locally and forward when connected:
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;
Production Shift Reports
Aggregate logged data into shift summaries — useful for 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_TYPEFUNCTION_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;
Data Compression: Swinging Door Algorithm
For long-term storage, the Swinging Door Trending (SDT) algorithm compresses time-series data by only recording points where the trend changes direction beyond 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_VARStorePoint := 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;
SDT typically achieves 85–95% compression on slowly-changing process data while preserving all significant transitions. This is the same algorithm used by OSIsoft PI and other enterprise historians.
Summary
| 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 |
Data is the new raw material of manufacturing. Build your logging infrastructure with the same reliability standards as your control logic — because the data you don't capture is the insight you'll never have.