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

RecordsFields per RecordApprox. Memory
1,0006 REAL + metadata~40 KB
5,0006 REAL + metadata~200 KB
10,0006 REAL + metadata~400 KB
50,0006 REAL + metadata~2 MB
Retain memory on current PLCs (S7-1500, CompactLogix, Beckhoff CX) is limited and varies a lot from one CPU to the next, so check the data sheet for yours. Size the buffer from the memory you have and the retention time you need.

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

PatternPurpose
Ring bufferFixed-size circular storage; never runs out of memory
Periodic loggingTimer-based with averaging for steady-state data
Event-driven loggingCapture alarms, state changes, and COV events
CSV formattingUniversal export format for analysis tools
File rotationDaily or size-based file management
Store-and-forwardBuffer locally, upload when historian is available
Shift reportsAggregate KPIs per production shift
Swinging DoorCompress time-series data 85–95% while preserving trends
Build the logging with the same care you give the control logic: a record the PLC never captured cannot be recovered later from the historian.