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:

  • High-speed capture: The PLC scans at 1–10ms; SCADA polls at 100ms–1s — you miss events in between
  • Autonomous operation: Logging continues even when SCADA is down
  • Event-driven capture: Log only when something interesting happens, not just on a timer
  • Pre-processing: Calculate averages, min/max, and rates before sending to the historian
  • 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_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;

    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_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. 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_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 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_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 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_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;

    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_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;

    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.