function result = prepare_steering_iddata(experimentRoot, outputDirectory)
%PREPARE_STEERING_IDDATA Convert steering snapshot CSV files to MATLAB iddata.
%   RESULT = PREPARE_STEERING_IDDATA() reads the two experiment folders next
%   to this file, detects every steering target step, aligns command and
%   feedback timestamps, resamples each response to 0.04 s, and saves:
%
%     dataEstLF/LR/RF/RR  uDiff -> delta actual steering angle
%     dataValLF/LR/RF/RR  independent validation data
%     closedLoopEst*      delta target angle -> delta actual angle
%     closedLoopVal*      independent closed-loop validation data
%
%   Each variable is a multi-experiment iddata object. Import one wheel at a
%   time into the System Identification app. The first experiment folder
%   (Kp=0.0035, dead zone=0.1 deg) is used for estimation and the third
%   folder (Kp=0.003, dead zone=0.1 deg) for validation. The second folder
%   with a 0.5 deg dead zone is retained as supplementary data and is not
%   used by this primary identification workflow. Raw CSV files are never
%   modified.
%
%   Example:
%     result = prepare_steering_iddata;
%     load(result.MatFile);
%     systemIdentification;
%
%   Units: uDiff in m/s, steering angle in degrees, time in seconds.

    if nargin < 1 || isempty(experimentRoot)
        experimentRoot = fileparts(mfilename('fullpath'));
    end
    if nargin < 2 || isempty(outputDirectory)
        outputDirectory = fullfile(experimentRoot, 'matlab_ready');
    end

    if exist('iddata', 'file') ~= 2
        error('prepare_steering_iddata:MissingToolbox', ...
            ['找不到 iddata。请安装或启用 MATLAB System Identification ' ...
             'Toolbox 后重新运行。']);
    end

    settings = struct();
    settings.SampleTimeSeconds = 0.04;
    settings.PreStepSeconds = 0.8;
    settings.PostStepSeconds = 4.0;
    settings.BaselineSeconds = 0.5;
    settings.NextStepGuardSeconds = 0.25;
    settings.MinimumTargetStepDegrees = 5.0;
    settings.MinimumSegmentSamples = 30;
    settings.EstimationFolder = '第一次0.0035--死区0.1度';
    settings.ValidationFolder = '第三次0.0030--死区0.1度';

    wheels = steeringWheelDefinitions();
    estimationPath = fullfile(experimentRoot, settings.EstimationFolder);
    validationPath = fullfile(experimentRoot, settings.ValidationFolder);
    requireFolder(estimationPath);
    requireFolder(validationPath);

    fprintf('读取估计数据：%s\n', estimationPath);
    estimationSegments = processExperimentFolder( ...
        estimationPath, 'Est', wheels, settings);
    fprintf('读取验证数据：%s\n', validationPath);
    validationSegments = processExperimentFolder( ...
        validationPath, 'Val', wheels, settings);

    allSegments = [estimationSegments, validationSegments];
    if isempty(allSegments)
        error('prepare_steering_iddata:NoSegments', ...
            '没有找到满足条件的舵角切换片段。');
    end

    dataEstLF = buildIdData(estimationSegments, 'LF', 'plant', settings);
    dataEstLR = buildIdData(estimationSegments, 'LR', 'plant', settings);
    dataEstRF = buildIdData(estimationSegments, 'RF', 'plant', settings);
    dataEstRR = buildIdData(estimationSegments, 'RR', 'plant', settings);

    dataValLF = buildIdData(validationSegments, 'LF', 'plant', settings);
    dataValLR = buildIdData(validationSegments, 'LR', 'plant', settings);
    dataValRF = buildIdData(validationSegments, 'RF', 'plant', settings);
    dataValRR = buildIdData(validationSegments, 'RR', 'plant', settings);

    closedLoopEstLF = buildIdData( ...
        estimationSegments, 'LF', 'closedLoop', settings);
    closedLoopEstLR = buildIdData( ...
        estimationSegments, 'LR', 'closedLoop', settings);
    closedLoopEstRF = buildIdData( ...
        estimationSegments, 'RF', 'closedLoop', settings);
    closedLoopEstRR = buildIdData( ...
        estimationSegments, 'RR', 'closedLoop', settings);

    closedLoopValLF = buildIdData( ...
        validationSegments, 'LF', 'closedLoop', settings);
    closedLoopValLR = buildIdData( ...
        validationSegments, 'LR', 'closedLoop', settings);
    closedLoopValRF = buildIdData( ...
        validationSegments, 'RF', 'closedLoop', settings);
    closedLoopValRR = buildIdData( ...
        validationSegments, 'RR', 'closedLoop', settings);

    metadata = buildMetadataTable(allSegments);
    samples = buildSampleTable(allSegments);

    if ~exist(outputDirectory, 'dir')
        mkdir(outputDirectory);
    end

    matFile = fullfile(outputDirectory, 'steering_identification_data.mat');
    metadataFile = fullfile(outputDirectory, 'steering_segment_metadata.csv');
    samplesFile = fullfile(outputDirectory, 'steering_identification_samples.csv');

    save(matFile, ...
        'dataEstLF', 'dataEstLR', 'dataEstRF', 'dataEstRR', ...
        'dataValLF', 'dataValLR', 'dataValRF', 'dataValRR', ...
        'closedLoopEstLF', 'closedLoopEstLR', ...
        'closedLoopEstRF', 'closedLoopEstRR', ...
        'closedLoopValLF', 'closedLoopValLR', ...
        'closedLoopValRF', 'closedLoopValRR', ...
        'metadata', 'settings', '-v7.3');
    writetable(metadata, metadataFile);
    writetable(samples, samplesFile);

    result = struct();
    result.MatFile = matFile;
    result.MetadataFile = metadataFile;
    result.SamplesFile = samplesFile;
    result.EstimationSegmentCount = numel(estimationSegments);
    result.ValidationSegmentCount = numel(validationSegments);
    result.SampleTimeSeconds = settings.SampleTimeSeconds;

    fprintf('\n处理完成。每个舵轮单独建立多实验数据：\n');
    printSegmentCounts(estimationSegments, validationSegments, wheels);
    fprintf('MATLAB辨识数据：%s\n', matFile);
    fprintf('片段元数据：%s\n', metadataFile);
    fprintf('长表CSV：%s\n', samplesFile);
    fprintf(['\n在工作区加载 MAT 文件后，将 dataEstLF 等变量作为估计数据，' ...
             '将对应的 dataValLF 等变量作为验证数据。\n']);
end

function wheels = steeringWheelDefinitions()
%STEERINGWHEELDEFINITIONS Describe the four differential steering modules.
    wheels = struct( ...
        'Code', {'LF', 'LR', 'RF', 'RR'}, ...
        'Name', {'LeftFront', 'LeftRear', 'RightFront', 'RightRear'}, ...
        'TargetColumn', { ...
            'TargetThLeftFront', 'TargetThLeftRear', ...
            'TargetThRightFront', 'TargetThRightRear'}, ...
        'ActualColumn', { ...
            'ActualThLeftFront', 'ActualThLeftRear', ...
            'ActualThRightFront', 'ActualThRightRear'}, ...
        'ActualTimeColumn', { ...
            'ActualThLeftFrontReceiveElapsedMs', ...
            'ActualThLeftRearReceiveElapsedMs', ...
            'ActualThRightFrontReceiveElapsedMs', ...
            'ActualThRightRearReceiveElapsedMs'}, ...
        'SentLeftColumn', { ...
            'SentLFLMps', 'SentLRLMps', 'SentRFLMps', 'SentRRLMps'}, ...
        'SentRightColumn', { ...
            'SentLFRMps', 'SentLRRMps', 'SentRFRMps', 'SentRRRMps'}, ...
        'LeftLimitedColumn', { ...
            'CommandLimitedLFL', 'CommandLimitedLRL', ...
            'CommandLimitedRFL', 'CommandLimitedRRL'}, ...
        'RightLimitedColumn', { ...
            'CommandLimitedLFR', 'CommandLimitedLRR', ...
            'CommandLimitedRFR', 'CommandLimitedRRR'}, ...
        'PairLimitedColumn', { ...
            'PairCommandLimitedLeftFront', ...
            'PairCommandLimitedLeftRear', ...
            'PairCommandLimitedRightFront', ...
            'PairCommandLimitedRightRear'});
end

function segments = processExperimentFolder(folder, datasetCode, wheels, settings)
%PROCESSEXPERIMENTFOLDER Convert all snapshot files in one experiment set.
    files = dir(fullfile(folder, '*_snapshot.csv'));
    if isempty(files)
        error('prepare_steering_iddata:NoSnapshotFiles', ...
            '目录中没有 *_snapshot.csv：%s', folder);
    end

    [~, order] = sort({files.name});
    files = files(order);
    segments = emptySegmentArray();

    for fileIndex = 1:numel(files)
        sourcePath = fullfile(files(fileIndex).folder, files(fileIndex).name);
        fprintf('  %s\n', files(fileIndex).name);
        snapshot = readNumericSnapshot(sourcePath);
        validateSnapshotColumns(snapshot, wheels);

        transitionType = regexprep( ...
            files(fileIndex).name, '_Car\d+_snapshot\.csv$', '');
        fileSegments = processSnapshot( ...
            snapshot, files(fileIndex).name, transitionType, ...
            datasetCode, wheels, settings);
        segments = [segments, fileSegments]; %#ok<AGROW>
    end
end

function snapshot = readNumericSnapshot(sourcePath)
%READNUMERICSNAPSHOT Read the numeric diagnostic snapshot without mutation.
    importOptions = detectImportOptions(sourcePath, 'Delimiter', ',');
    importOptions = setvartype( ...
        importOptions, importOptions.VariableNames, 'double');
    snapshot = readtable(sourcePath, importOptions);
    if isempty(snapshot)
        error('prepare_steering_iddata:EmptySnapshot', ...
            'CSV中没有数据：%s', sourcePath);
    end
end

function validateSnapshotColumns(snapshot, wheels)
%VALIDATESNAPSHOTCOLUMNS Ensure every signal needed for identification exists.
    required = { ...
        'ElapsedMs', 'ControlElapsedMs', 'WheelCommandElapsedMs', ...
        'WheelCommandSuppressed', 'DiffSteerKp', 'DiffSteerKi', ...
        'DiffSteerKd', 'DiffSteerDeadZone', ...
        'DiffSteerRateFeedforwardGain'};

    for wheelIndex = 1:numel(wheels)
        wheel = wheels(wheelIndex);
        required = [required, { ... %#ok<AGROW>
            wheel.TargetColumn, wheel.ActualColumn, ...
            wheel.ActualTimeColumn, wheel.SentLeftColumn, ...
            wheel.SentRightColumn, wheel.LeftLimitedColumn, ...
            wheel.RightLimitedColumn, wheel.PairLimitedColumn}];
    end

    missing = setdiff(required, snapshot.Properties.VariableNames);
    if ~isempty(missing)
        error('prepare_steering_iddata:MissingColumns', ...
            'snapshot CSV缺少字段：%s', strjoin(missing, ', '));
    end
end

function segments = processSnapshot( ...
    snapshot, sourceFile, transitionType, datasetCode, wheels, settings)
%PROCESSSNAPSHOT Extract one iddata experiment for every wheel target step.
    elapsedMs = snapshot.ElapsedMs;
    finiteElapsed = elapsedMs(isfinite(elapsedMs));
    if isempty(finiteElapsed)
        error('prepare_steering_iddata:InvalidTime', ...
            '%s没有有效ElapsedMs。', sourceFile);
    end
    timeOriginMs = finiteElapsed(1);

    kp = constantParameter(snapshot.DiffSteerKp, 'DiffSteerKp', sourceFile);
    ki = constantParameter(snapshot.DiffSteerKi, 'DiffSteerKi', sourceFile);
    kd = constantParameter(snapshot.DiffSteerKd, 'DiffSteerKd', sourceFile);
    deadZone = constantParameter( ...
        snapshot.DiffSteerDeadZone, 'DiffSteerDeadZone', sourceFile);
    feedforwardGain = constantParameter( ...
        snapshot.DiffSteerRateFeedforwardGain, ...
        'DiffSteerRateFeedforwardGain', sourceFile);

    segments = emptySegmentArray();
    for wheelIndex = 1:numel(wheels)
        wheel = wheels(wheelIndex);
        wheelSegments = extractWheelSegments( ...
            snapshot, timeOriginMs, sourceFile, transitionType, ...
            datasetCode, wheel, settings, kp, ki, kd, ...
            deadZone, feedforwardGain);
        segments = [segments, wheelSegments]; %#ok<AGROW>

        if numel(wheelSegments) ~= 20
            warning('prepare_steering_iddata:UnexpectedStepCount', ...
                '%s %s检测到%d次切换，预期为20次。', ...
                sourceFile, wheel.Code, numel(wheelSegments));
        end
    end
end

function segments = extractWheelSegments( ...
    snapshot, timeOriginMs, sourceFile, transitionType, datasetCode, ...
    wheel, settings, kp, ki, kd, deadZone, feedforwardGain)
%EXTRACTWHEELSEGMENTS Align and split one steering module's time series.
    targetTime = (snapshot.ControlElapsedMs - timeOriginMs) / 1000;
    targetAngle = snapshot.(wheel.TargetColumn);
    [targetTime, targetAngle] = compactTimedSignal(targetTime, targetAngle);

    commandTime = (snapshot.WheelCommandElapsedMs - timeOriginMs) / 1000;
    uDiff = (snapshot.(wheel.SentRightColumn) - ...
             snapshot.(wheel.SentLeftColumn)) / 2;
    [commandTime, uDiff] = compactTimedSignal(commandTime, uDiff);

    actualTime = (snapshot.(wheel.ActualTimeColumn) - timeOriginMs) / 1000;
    actualAngle = snapshot.(wheel.ActualColumn);
    [actualTime, actualAngle] = compactTimedSignal(actualTime, actualAngle);

    if numel(targetTime) < 2 || numel(commandTime) < 2 || ...
            numel(actualTime) < 2
        warning('prepare_steering_iddata:InsufficientSignal', ...
            '%s %s的时间序列不足，已跳过。', sourceFile, wheel.Code);
        segments = emptySegmentArray();
        return;
    end

    rawEventIndices = find( ...
        abs(diff(targetAngle)) >= settings.MinimumTargetStepDegrees) + 1;
    eventIndices = suppressNearbyEvents( ...
        rawEventIndices, targetTime, settings.NextStepGuardSeconds);
    segments = emptySegmentArray();

    for eventNumber = 1:numel(eventIndices)
        targetIndex = eventIndices(eventNumber);
        eventTime = targetTime(targetIndex);
        targetStep = targetAngle(targetIndex) - targetAngle(targetIndex - 1);

        segmentStart = eventTime - settings.PreStepSeconds;
        segmentEnd = eventTime + settings.PostStepSeconds;
        if eventNumber < numel(eventIndices)
            nextEventTime = targetTime(eventIndices(eventNumber + 1));
            segmentEnd = min( ...
                segmentEnd, nextEventTime - settings.NextStepGuardSeconds);
        end

        commonStart = max([ ...
            segmentStart, targetTime(1), commandTime(1), actualTime(1)]);
        commonEnd = min([ ...
            segmentEnd, targetTime(end), commandTime(end), actualTime(end)]);
        firstGridIndex = ceil(commonStart / settings.SampleTimeSeconds);
        lastGridIndex = floor(commonEnd / settings.SampleTimeSeconds);
        commonTime = (firstGridIndex:lastGridIndex)' * ...
            settings.SampleTimeSeconds;

        if numel(commonTime) < settings.MinimumSegmentSamples
            warning('prepare_steering_iddata:ShortSegment', ...
                '%s %s第%d次切换数据过短，已跳过。', ...
                sourceFile, wheel.Code, eventNumber);
            continue;
        end

        targetResampled = interp1( ...
            targetTime, targetAngle, commonTime, 'previous');
        inputResampled = interp1( ...
            commandTime, uDiff, commonTime, 'previous');
        outputResampled = interp1( ...
            actualTime, actualAngle, commonTime, 'linear');
        relativeTime = commonTime - eventTime;

        baselineMask = relativeTime >= -settings.BaselineSeconds & ...
                       relativeTime < -settings.SampleTimeSeconds / 2;
        if ~any(baselineMask)
            baselineMask = relativeTime < 0;
        end
        if ~any(baselineMask)
            warning('prepare_steering_iddata:MissingBaseline', ...
                '%s %s第%d次切换没有切换前基线，已跳过。', ...
                sourceFile, wheel.Code, eventNumber);
            continue;
        end

        inputBaseline = finiteMedian(inputResampled(baselineMask));
        outputBaseline = finiteMedian(outputResampled(baselineMask));
        targetBaseline = finiteMedian(targetResampled(baselineMask));
        inputDelta = inputResampled - inputBaseline;
        outputDelta = outputResampled - outputBaseline;
        targetDelta = targetResampled - targetBaseline;

        valid = isfinite(relativeTime) & isfinite(inputDelta) & ...
                isfinite(outputDelta) & isfinite(targetDelta);
        if nnz(valid) < settings.MinimumSegmentSamples
            warning('prepare_steering_iddata:InvalidSegment', ...
                '%s %s第%d次切换有效样本不足，已跳过。', ...
                sourceFile, wheel.Code, eventNumber);
            continue;
        end

        relativeTime = relativeTime(valid);
        inputDelta = inputDelta(valid);
        outputDelta = outputDelta(valid);
        targetDelta = targetDelta(valid);

        if segmentContainsUnsafeCommand( ...
                snapshot, timeOriginMs, commonStart, commonEnd, wheel)
            warning('prepare_steering_iddata:UnsafeCommandSegment', ...
                ['%s %s第%d次切换出现命令限幅或安全抑制，' ...
                 '为保持线性辨识条件已跳过。'], ...
                sourceFile, wheel.Code, eventNumber);
            continue;
        end

        experimentName = sprintf('%s_%s_%s_E%02d', ...
            datasetCode, transitionType, wheel.Code, eventNumber);
        segment = struct();
        segment.Dataset = datasetCode;
        segment.Wheel = wheel.Code;
        segment.SourceFile = sourceFile;
        segment.TransitionType = transitionType;
        segment.EventIndex = eventNumber;
        segment.ExperimentName = experimentName;
        segment.EventTimeSeconds = eventTime;
        segment.TargetStepDegrees = targetStep;
        segment.Kp = kp;
        segment.Ki = ki;
        segment.Kd = kd;
        segment.DeadZoneDegrees = deadZone;
        segment.FeedforwardGain = feedforwardGain;
        segment.InputBaselineMps = inputBaseline;
        segment.OutputBaselineDegrees = outputBaseline;
        segment.TimeSeconds = relativeTime;
        segment.UDiffMps = inputDelta;
        segment.ActualAngleDeltaDegrees = outputDelta;
        segment.TargetAngleDeltaDegrees = targetDelta;
        segments(end + 1) = segment; %#ok<AGROW>
    end
end

function [time, value] = compactTimedSignal(time, value)
%COMPACTTIMEDSIGNAL Remove missing data and keep the last value per timestamp.
    valid = isfinite(time) & isfinite(value);
    time = time(valid);
    value = value(valid);
    [time, order] = sort(time);
    value = value(order);
    [time, uniqueIndices] = unique(time, 'last');
    value = value(uniqueIndices);
end

function eventIndices = suppressNearbyEvents(rawIndices, time, minimumGap)
%SUPPRESSNEARBYEVENTS Treat rapid intermediate target updates as one step.
    eventIndices = zeros(0, 1);
    for index = 1:numel(rawIndices)
        candidate = rawIndices(index);
        if isempty(eventIndices) || ...
                time(candidate) - time(eventIndices(end)) >= minimumGap
            eventIndices(end + 1, 1) = candidate; %#ok<AGROW>
        end
    end
end

function unsafe = segmentContainsUnsafeCommand( ...
    snapshot, timeOriginMs, startTime, endTime, wheel)
%SEGMENTCONTAINSUNSAFECOMMAND Reject saturated or suppressed responses.
    snapshotTime = (snapshot.ElapsedMs - timeOriginMs) / 1000;
    inSegment = snapshotTime >= startTime & snapshotTime <= endTime;
    unsafe = any(snapshot.WheelCommandSuppressed(inSegment) ~= 0) || ...
             any(snapshot.(wheel.LeftLimitedColumn)(inSegment) ~= 0) || ...
             any(snapshot.(wheel.RightLimitedColumn)(inSegment) ~= 0) || ...
             any(snapshot.(wheel.PairLimitedColumn)(inSegment) ~= 0);
end

function value = constantParameter(values, parameterName, sourceFile)
%CONSTANTPARAMETER Verify that one experiment did not change configuration.
    finiteValues = values(isfinite(values));
    if isempty(finiteValues)
        error('prepare_steering_iddata:MissingParameter', ...
            '%s没有有效的%s。', sourceFile, parameterName);
    end
    value = finiteMedian(finiteValues);
    tolerance = max(1e-9, abs(value) * 1e-6);
    if any(abs(finiteValues - value) > tolerance)
        error('prepare_steering_iddata:ChangingParameter', ...
            '%s中的%s在记录期间发生变化，不能作为单一实验。', ...
            sourceFile, parameterName);
    end
end

function value = finiteMedian(values)
%FINITEMEDIAN Return the median after removing non-finite samples.
    values = values(isfinite(values));
    if isempty(values)
        value = NaN;
    else
        value = median(values);
    end
end

function data = buildIdData(segments, wheelCode, inputKind, settings)
%BUILDIDDATA Build one multi-experiment iddata object for one steering module.
    selected = segments(strcmp({segments.Wheel}, wheelCode));
    if isempty(selected)
        error('prepare_steering_iddata:MissingWheelData', ...
            '没有找到%s舵轮的%s数据。', wheelCode, inputKind);
    end

    outputs = cell(1, numel(selected));
    inputs = cell(1, numel(selected));
    experimentNames = cell(1, numel(selected));
    for index = 1:numel(selected)
        outputs{index} = selected(index).ActualAngleDeltaDegrees(:);
        if strcmp(inputKind, 'plant')
            inputs{index} = selected(index).UDiffMps(:);
        else
            inputs{index} = selected(index).TargetAngleDeltaDegrees(:);
        end
        experimentNames{index} = selected(index).ExperimentName;
    end

    data = iddata(outputs, inputs, settings.SampleTimeSeconds);
    data.ExperimentName = experimentNames;
    data.OutputName = {'actualSteeringAngleDelta'};
    data.OutputUnit = {'deg'};
    data.TimeUnit = 'seconds';
    if strcmp(inputKind, 'plant')
        data.Name = sprintf('%s steering plant: uDiff to angle', wheelCode);
        data.InputName = {'uDiff'};
        data.InputUnit = {'m/s'};
    else
        data.Name = sprintf('%s existing closed loop: target to angle', wheelCode);
        data.InputName = {'targetSteeringAngleDelta'};
        data.InputUnit = {'deg'};
    end
end

function metadata = buildMetadataTable(segments)
%BUILDMETADATATABLE Create one row of traceability data per step response.
    count = numel(segments);
    dataset = cell(count, 1);
    wheel = cell(count, 1);
    sourceFile = cell(count, 1);
    transitionType = cell(count, 1);
    experimentName = cell(count, 1);
    eventIndex = zeros(count, 1);
    eventTimeSeconds = zeros(count, 1);
    targetStepDegrees = zeros(count, 1);
    kp = zeros(count, 1);
    ki = zeros(count, 1);
    kd = zeros(count, 1);
    deadZoneDegrees = zeros(count, 1);
    feedforwardGain = zeros(count, 1);
    inputBaselineMps = zeros(count, 1);
    outputBaselineDegrees = zeros(count, 1);
    sampleCount = zeros(count, 1);

    for index = 1:count
        segment = segments(index);
        dataset{index} = segment.Dataset;
        wheel{index} = segment.Wheel;
        sourceFile{index} = segment.SourceFile;
        transitionType{index} = segment.TransitionType;
        experimentName{index} = segment.ExperimentName;
        eventIndex(index) = segment.EventIndex;
        eventTimeSeconds(index) = segment.EventTimeSeconds;
        targetStepDegrees(index) = segment.TargetStepDegrees;
        kp(index) = segment.Kp;
        ki(index) = segment.Ki;
        kd(index) = segment.Kd;
        deadZoneDegrees(index) = segment.DeadZoneDegrees;
        feedforwardGain(index) = segment.FeedforwardGain;
        inputBaselineMps(index) = segment.InputBaselineMps;
        outputBaselineDegrees(index) = segment.OutputBaselineDegrees;
        sampleCount(index) = numel(segment.TimeSeconds);
    end

    metadata = table( ...
        dataset, wheel, sourceFile, transitionType, experimentName, ...
        eventIndex, eventTimeSeconds, targetStepDegrees, ...
        kp, ki, kd, deadZoneDegrees, feedforwardGain, ...
        inputBaselineMps, outputBaselineDegrees, sampleCount, ...
        'VariableNames', { ...
            'Dataset', 'Wheel', 'SourceFile', 'TransitionType', ...
            'ExperimentName', 'EventIndex', 'EventTimeSeconds', ...
            'TargetStepDegrees', 'Kp', 'Ki', 'Kd', ...
            'DeadZoneDegrees', 'FeedforwardGain', ...
            'InputBaselineMps', 'OutputBaselineDegrees', 'SampleCount'});
end

function samples = buildSampleTable(segments)
%BUILDSAMPLETABLE Export a tidy long-form table for manual inspection.
    totalSamples = sum(arrayfun( ...
        @(segment) numel(segment.TimeSeconds), segments));
    dataset = cell(totalSamples, 1);
    wheel = cell(totalSamples, 1);
    experimentName = cell(totalSamples, 1);
    sourceFile = cell(totalSamples, 1);
    transitionType = cell(totalSamples, 1);
    eventIndex = zeros(totalSamples, 1);
    timeSeconds = zeros(totalSamples, 1);
    uDiffMps = zeros(totalSamples, 1);
    actualAngleDeltaDegrees = zeros(totalSamples, 1);
    targetAngleDeltaDegrees = zeros(totalSamples, 1);
    kp = zeros(totalSamples, 1);
    deadZoneDegrees = zeros(totalSamples, 1);

    firstRow = 1;
    for index = 1:numel(segments)
        segment = segments(index);
        count = numel(segment.TimeSeconds);
        rows = (firstRow:(firstRow + count - 1))';
        dataset(rows) = repmat({segment.Dataset}, count, 1);
        wheel(rows) = repmat({segment.Wheel}, count, 1);
        experimentName(rows) = repmat( ...
            {segment.ExperimentName}, count, 1);
        sourceFile(rows) = repmat({segment.SourceFile}, count, 1);
        transitionType(rows) = repmat( ...
            {segment.TransitionType}, count, 1);
        eventIndex(rows) = segment.EventIndex;
        timeSeconds(rows) = segment.TimeSeconds;
        uDiffMps(rows) = segment.UDiffMps;
        actualAngleDeltaDegrees(rows) = segment.ActualAngleDeltaDegrees;
        targetAngleDeltaDegrees(rows) = segment.TargetAngleDeltaDegrees;
        kp(rows) = segment.Kp;
        deadZoneDegrees(rows) = segment.DeadZoneDegrees;
        firstRow = firstRow + count;
    end

    samples = table( ...
        dataset, wheel, experimentName, sourceFile, transitionType, ...
        eventIndex, timeSeconds, uDiffMps, ...
        actualAngleDeltaDegrees, targetAngleDeltaDegrees, ...
        kp, deadZoneDegrees, ...
        'VariableNames', { ...
            'Dataset', 'Wheel', 'ExperimentName', 'SourceFile', ...
            'TransitionType', 'EventIndex', 'TimeSeconds', ...
            'UDiffMps', 'ActualAngleDeltaDegrees', ...
            'TargetAngleDeltaDegrees', 'Kp', 'DeadZoneDegrees'});
end

function segments = emptySegmentArray()
%EMPTYSEGMENTARRAY Return an empty struct with the stable segment schema.
    template = struct( ...
        'Dataset', '', ...
        'Wheel', '', ...
        'SourceFile', '', ...
        'TransitionType', '', ...
        'EventIndex', 0, ...
        'ExperimentName', '', ...
        'EventTimeSeconds', 0, ...
        'TargetStepDegrees', 0, ...
        'Kp', 0, ...
        'Ki', 0, ...
        'Kd', 0, ...
        'DeadZoneDegrees', 0, ...
        'FeedforwardGain', 0, ...
        'InputBaselineMps', 0, ...
        'OutputBaselineDegrees', 0, ...
        'TimeSeconds', zeros(0, 1), ...
        'UDiffMps', zeros(0, 1), ...
        'ActualAngleDeltaDegrees', zeros(0, 1), ...
        'TargetAngleDeltaDegrees', zeros(0, 1));
    segments = template([]);
end

function printSegmentCounts(estimationSegments, validationSegments, wheels)
%PRINTSEGMENTCOUNTS Report the experiment count for every output iddata pair.
    for wheelIndex = 1:numel(wheels)
        wheelCode = wheels(wheelIndex).Code;
        estimationCount = nnz(strcmp( ...
            {estimationSegments.Wheel}, wheelCode));
        validationCount = nnz(strcmp( ...
            {validationSegments.Wheel}, wheelCode));
        fprintf('  %s：估计%d段，验证%d段\n', ...
            wheelCode, estimationCount, validationCount);
    end
end

function requireFolder(folder)
%REQUIREFOLDER Fail early when the expected experiment folder is absent.
    if ~exist(folder, 'dir')
        error('prepare_steering_iddata:MissingFolder', ...
            '找不到实验目录：%s', folder);
    end
end
