diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index 07630bb..6375150 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -34,7 +34,11 @@ ## Validation Routing -- Always run `scripts/run_matlab_tests.sh --suite project` for package boundary or public surface changes. -- DTA changes: also run `scripts/run_matlab_tests.sh --suite labkit/dta`; add `--suite apps/electrochem` when app-facing behavior may be affected. -- Biosignal changes: also run `scripts/run_matlab_tests.sh --suite labkit/biosignal`; add `--suite apps/wearable` when app-facing behavior may be affected. -- UI changes: also run `scripts/run_matlab_tests.sh --suite labkit/ui`; add `--suite apps --gui` for layout, launch, callback, or app shell changes. +- Always run `buildtool testProject` for package boundary or public surface changes. +- DTA changes: also run `buildtool testLabkitDta`; add + `buildtool testAppsElectrochem` when app-facing behavior may be affected. +- Biosignal changes: also run `buildtool testLabkitBiosignal`; add + `buildtool testAppsWearableGui` when app-facing behavior may be affected. +- UI changes: also run `buildtool testLabkitUi`; add + `buildtool testLabkitUiGui testAppsGui` for layout, launch, callback, or app + shell changes. diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml index e1a2f5a..5cfee63 100644 --- a/.github/workflows/matlab-tests.yml +++ b/.github/workflows/matlab-tests.yml @@ -15,6 +15,30 @@ env: MATLAB_RELEASE: R2025a jobs: + shell-wrapper: + name: Shell Wrapper Checks + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Check Bash wrapper syntax + run: bash -n scripts/run_matlab_tests.sh + + - name: Check Bash wrapper help + run: bash scripts/run_matlab_tests.sh --help + + - name: Check Bash wrapper build task smoke + run: MATLAB_CMD=true bash scripts/run_matlab_tests.sh testProject + + - name: Check removed selector flags are rejected + run: | + if bash scripts/run_matlab_tests.sh --suite; then + echo "Expected --suite to be rejected" + exit 1 + fi + quality: name: Quality Guardrails runs-on: ubuntu-latest @@ -31,7 +55,7 @@ jobs: release: ${{ env.MATLAB_RELEASE }} - name: Prepare artifact directories - run: mkdir -p artifacts/logs artifacts/test-results/html + run: mkdir -p artifacts/logs - name: Run quality guardrails uses: matlab-actions/run-build@v3 @@ -47,9 +71,8 @@ jobs: if-no-files-found: warn retention-days: 14 path: | - artifacts/test-results/junit.xml - artifacts/test-results/html/** - artifacts/logs/matlab.log + artifacts/test-results/** + artifacts/logs/** unit: name: Unit And Coverage @@ -67,7 +90,7 @@ jobs: release: ${{ env.MATLAB_RELEASE }} - name: Prepare artifact directories - run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/coverage/html + run: mkdir -p artifacts/logs - name: Run unit tests and coverage uses: matlab-actions/run-build@v3 @@ -83,11 +106,9 @@ jobs: if-no-files-found: warn retention-days: 14 path: | - artifacts/test-results/junit.xml - artifacts/test-results/html/** - artifacts/coverage/cobertura.xml - artifacts/coverage/html/** - artifacts/logs/matlab.log + artifacts/test-results/** + artifacts/coverage/** + artifacts/logs/** integration: name: Integration Tests @@ -105,7 +126,7 @@ jobs: release: ${{ env.MATLAB_RELEASE }} - name: Prepare artifact directories - run: mkdir -p artifacts/logs artifacts/test-results/html + run: mkdir -p artifacts/logs - name: Run integration tests uses: matlab-actions/run-build@v3 @@ -121,9 +142,8 @@ jobs: if-no-files-found: warn retention-days: 14 path: | - artifacts/test-results/junit.xml - artifacts/test-results/html/** - artifacts/logs/matlab.log + artifacts/test-results/** + artifacts/logs/** gui-structural: name: GUI Structural Tests @@ -142,7 +162,7 @@ jobs: release: ${{ env.MATLAB_RELEASE }} - name: Prepare artifact directories - run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/gui/trace artifacts/gui/snapshots + run: mkdir -p artifacts/logs - name: Run GUI structural tests uses: matlab-actions/run-build@v3 @@ -158,11 +178,9 @@ jobs: if-no-files-found: warn retention-days: 14 path: | - artifacts/test-results/junit.xml - artifacts/test-results/html/** - artifacts/gui/trace/** - artifacts/gui/snapshots/** - artifacts/logs/matlab.log + artifacts/test-results/** + artifacts/gui/** + artifacts/logs/** gui-gesture: name: GUI Gesture Tests @@ -182,7 +200,7 @@ jobs: release: ${{ env.MATLAB_RELEASE }} - name: Prepare artifact directories - run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/gui/trace artifacts/gui/snapshots + run: mkdir -p artifacts/logs - name: Run GUI gesture tests uses: matlab-actions/run-build@v3 @@ -198,8 +216,6 @@ jobs: if-no-files-found: warn retention-days: 14 path: | - artifacts/test-results/junit.xml - artifacts/test-results/html/** - artifacts/gui/trace/** - artifacts/gui/snapshots/** - artifacts/logs/matlab.log + artifacts/test-results/** + artifacts/gui/** + artifacts/logs/** diff --git a/apps/AGENTS.md b/apps/AGENTS.md index a0c1a01..c959bea 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -38,8 +38,12 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl ## Validation Routing -- Electrochem app change: `scripts/run_matlab_tests.sh --suite apps/electrochem`; add `--gui` for layout, launch, or callback wiring. -- DIC app change: `scripts/run_matlab_tests.sh --suite apps/dic --gui`. -- Image measurement app change: `scripts/run_matlab_tests.sh --suite apps/image_measurement --gui`. -- Wearable app change: `scripts/run_matlab_tests.sh --suite apps/wearable --gui`; add `--suite labkit/biosignal` when the biosignal facade contract may be affected. -- App entrypoint or boundary changes also run `scripts/run_matlab_tests.sh --suite project`. +- Electrochem app change: `buildtool testAppsElectrochem`; use + `buildtool testAppsElectrochemGui` for layout, launch, or callback wiring. +- DIC app change: `buildtool testAppsDicGui`. +- Image measurement app change: `buildtool testAppsImageMeasurement`; use + `buildtool testAppsImageMeasurementGui` for layout, launch, or callback wiring. +- Wearable app change: `buildtool testAppsWearableGui`; add + `buildtool testLabkitBiosignal` when the biosignal facade contract may be + affected. +- App entrypoint or boundary changes also run `buildtool testProject`. diff --git a/apps/electrochem/electrochemWorkflow.m b/apps/electrochem/electrochemWorkflow.m index 40b0958..eac8d72 100644 --- a/apps/electrochem/electrochemWorkflow.m +++ b/apps/electrochem/electrochemWorkflow.m @@ -2,8 +2,8 @@ %ELECTROCHEMWORKFLOW Dispatch app-owned electrochem workflow helpers. % Expected caller: electrochem app tests and migration-time workflow checks. % Inputs are an app key, a workflow command, and command-specific arguments. -% Outputs match the selected app-owned helper. File side effects are limited to -% CSV export commands. +% Outputs match the selected app-owned helper. Side effects are limited to CSV +% export commands and app-owned plot drawing commands on caller axes. switch string(appKey) case "chronoOverlay" diff --git a/apps/electrochem/private/chronoOverlayWorkflow.m b/apps/electrochem/private/chronoOverlayWorkflow.m index ed828e0..43b3e48 100644 --- a/apps/electrochem/private/chronoOverlayWorkflow.m +++ b/apps/electrochem/private/chronoOverlayWorkflow.m @@ -1,12 +1,14 @@ % App-owned chrono overlay workflow helper dispatch. Expected caller: % labkit_ChronoOverlay_app callbacks and workflow tests. % Inputs are a command string plus the original helper arguments; outputs match -% the selected helper. This helper has no file side effects. +% the selected helper. Side effects are limited to drawing app-owned overlay +% plots on caller axes. function varargout = chronoOverlayWorkflow(command, varargin) %CHRONOOVERLAYWORKFLOW Dispatch app-owned chrono overlay helpers. % Expected caller: labkit_ChronoOverlay_app callbacks and temporary compatibility % workflow tests. Inputs are a command string plus the original helper arguments. -% Outputs match the selected helper. This helper has no file side effects. +% Outputs match the selected helper. Side effects are limited to drawing +% app-owned overlay plots on caller axes. switch string(command) case "alignByPulseGap" diff --git a/apps/electrochem/private/cicWorkflow.m b/apps/electrochem/private/cicWorkflow.m index 558aa82..946a8b9 100644 --- a/apps/electrochem/private/cicWorkflow.m +++ b/apps/electrochem/private/cicWorkflow.m @@ -1,12 +1,13 @@ % App-owned CIC workflow helper dispatch. Expected caller: labkit_CIC_app -% callbacks and workflow tests. Inputs are a command -% string plus the original helper arguments; outputs match the selected helper. -% Side effects are limited to writeResultsCSV file writes. +% callbacks and workflow tests. Inputs are a command string plus the original +% helper arguments; outputs match the selected helper. Side effects are limited +% to CSV export writes and drawing app-owned plot annotations on caller axes. function varargout = cicWorkflow(command, varargin) %CICWORKFLOW Dispatch app-owned CIC analysis/export helpers. % Expected caller: labkit_CIC_app callbacks and workflow tests. % Inputs are a command string plus the original helper arguments. Outputs match -% the selected helper. Side effects are limited to writeResultsCSV file writes. +% the selected helper. Side effects are limited to CSV export writes and drawing +% app-owned plot annotations on caller axes. switch string(command) case "computeCIC" @@ -17,6 +18,20 @@ varargout{1} = buildResultsTable(varargin{:}); case "writeResultsCSV" [varargout{1:nargout}] = writeResultsCSV(varargin{:}); + case "formatChargeDensity" + varargout{1} = formatChargeDensity(varargin{:}); + case "formatMaybeNum" + varargout{1} = formatMaybeNum(varargin{:}); + case "interp1Safe" + varargout{1} = interp1Safe(varargin{:}); + case "shadeWindow" + shadeWindow(varargin{:}); + case "addBaselineYLines" + addBaselineYLines(varargin{:}); + case "addPaperStyleVTAnnotations" + addPaperStyleVTAnnotations(varargin{:}); + case "addPaperStyleITAnnotations" + addPaperStyleITAnnotations(varargin{:}); otherwise error('labkit:CIC:UnknownWorkflowCommand', ... 'Unknown CIC workflow helper command: %s.', command); diff --git a/apps/electrochem/private/runCICApp.m b/apps/electrochem/private/runCICApp.m index 20d4c3b..d987edc 100644 --- a/apps/electrochem/private/runCICApp.m +++ b/apps/electrochem/private/runCICApp.m @@ -386,14 +386,19 @@ function refreshResultsSummary() A = it.analysis; S.txtDetect.Value = sprintf('%s | %s', A.detectMode, A.detectMsg); S.txtDelay.Value = sprintf('%.3f us', 1e6 * A.delay_s); - S.txtArea.Value = formatMaybeNum(A.area_cm2,'%.8g cm^2'); + S.txtArea.Value = cicWorkflow("formatMaybeNum", A.area_cm2,'%.8g cm^2'); S.txtEmc.Value = sprintf('%.6f V @ %.6fus', A.Emc, 1e6*A.t_emc); S.txtEma.Value = sprintf('%.6f V @ %.6fus', A.Ema, 1e6*A.t_ema); - S.txtQc.Value = formatChargeDensity(A.Qc_C, A.CICc_mCcm2, ddCICUnit.Value); - S.txtQa.Value = formatChargeDensity(A.Qa_C, A.CICa_mCcm2, ddCICUnit.Value); - S.txtQt.Value = formatChargeDensity(A.Qt_C, A.CICt_mCcm2, ddCICUnit.Value); + S.txtQc.Value = cicWorkflow("formatChargeDensity", A.Qc_C, A.CICc_mCcm2, ddCICUnit.Value); + S.txtQa.Value = cicWorkflow("formatChargeDensity", A.Qa_C, A.CICa_mCcm2, ddCICUnit.Value); + S.txtQt.Value = cicWorkflow("formatChargeDensity", A.Qt_C, A.CICt_mCcm2, ddCICUnit.Value); + if A.safe + safeText = 'SAFE'; + else + safeText = 'UNSAFE'; + end S.txtSafe.Value = sprintf('%s | Emc>=%.3f? %d | Ema<=%.3f? %d', ... - ternary(A.safe,'SAFE','UNSAFE'), A.cathLimit, A.cathOK, A.anodLimit, A.anodOK); + safeText, A.cathLimit, A.cathOK, A.anodLimit, A.anodOK); S.txtBest.Value = bestSafeString(); end @@ -500,12 +505,12 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) if strcmp(xChoice,'Sample #') x = A.pt; xlab = 'Sample #'; - cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); - cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); - anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); - anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); - emcX = interp1Safe(A.t, A.pt, A.t_emc); - emaX = interp1Safe(A.t, A.pt, A.t_ema); + cathStartX = cicWorkflow("interp1Safe", A.t, A.pt, A.pulse.cath_start); + cathEndX = cicWorkflow("interp1Safe", A.t, A.pt, A.pulse.cath_end); + anodStartX = cicWorkflow("interp1Safe", A.t, A.pt, A.pulse.anod_start); + anodEndX = cicWorkflow("interp1Safe", A.t, A.pt, A.pulse.anod_end); + emcX = cicWorkflow("interp1Safe", A.t, A.pt, A.t_emc); + emaX = cicWorkflow("interp1Safe", A.t, A.pt, A.t_ema); else x = A.t; xlab = 'Time (s)'; @@ -525,8 +530,8 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) hold(ax,'on'); if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); + cicWorkflow("shadeWindow", ax, cathStartX, cathEndX, [0.85 0.93 1.00]); + cicWorkflow("shadeWindow", ax, anodStartX, anodEndX, [1.00 0.92 0.85]); end if cbShowLimits.Value @@ -536,17 +541,23 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) 'Color',[0.85 0.2 0.2],'LabelHorizontalAlignment','left'); end - addBaselineYLines(ax, A); + cicWorkflow("addBaselineYLines", ax, A); if cbShowMarkers.Value xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); - addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); + cicWorkflow("addPaperStyleVTAnnotations", ax, A, xChoice, ... + cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); end hold(ax,'off'); - ttl = sprintf('%s | VT | %s', itName(), ternary(A.safe,'SAFE','UNSAFE')); + if A.safe + safeText = 'SAFE'; + else + safeText = 'UNSAFE'; + end + ttl = sprintf('%s | VT | %s', itName(), safeText); else y = A.Im; ylab = 'Im (A)'; @@ -555,8 +566,8 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) hold(ax,'on'); if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); + cicWorkflow("shadeWindow", ax, cathStartX, cathEndX, [0.85 0.93 1.00]); + cicWorkflow("shadeWindow", ax, anodStartX, anodEndX, [1.00 0.92 0.85]); end if cbShowMarkers.Value @@ -564,7 +575,8 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); - addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); + cicWorkflow("addPaperStyleITAnnotations", ax, A, xChoice, ... + cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); end hold(ax,'off'); ttl = sprintf('%s | IT | |I|max = %.4g A', itName(), A.ampEstimate_A); @@ -573,7 +585,11 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) title(ax, ttl, 'Interpreter','none'); xlabel(ax, xlab); ylabel(ax, ylab); - grid(ax, ternary(showGrid,'on','off')); + if showGrid + grid(ax, 'on'); + else + grid(ax, 'off'); + end end function nm = itName() @@ -626,685 +642,3 @@ function addLog(msg) end end - -%% App-local analysis -function A = computeCIC(item, opts) -%COMPUTECIC Compute legacy-compatible CIC / voltage-transient metrics. - - if nargin < 2 - opts = struct(); - end - opts = fillCICOptions(opts); - - A = struct(); - A.ok = false; - A.message = ''; - A.delay_s = opts.delay_s; - A.cathLimit = opts.cathLimit; - A.anodLimit = opts.anodLimit; - A.area_cm2 = chooseArea(item, opts); - A.usedMeasuredCurrent = opts.usedMeasuredCurrent; - A.logOnFailure = false; - - [curve, okCurve, msgCurve] = mainCurve(item); - if ~okCurve - A.message = msgCurve; - A.logOnFailure = true; - return; - end - - t = labkit.dta.getColumn(curve, 'T'); - Vf = labkit.dta.getColumn(curve, 'Vf'); - Im = labkit.dta.getColumn(curve, 'Im'); - pt = labkit.dta.getColumn(curve, 'Pt'); - if isempty(pt) - pt = (0:numel(t)-1).'; - end - - valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); - t = t(valid); - Vf = Vf(valid); - Im = Im(valid); - pt = pt(valid); - if numel(t) < 5 - A.message = 'Not enough valid T/Vf/Im points.'; - return; - end - - A.t = t; - A.Vf = Vf; - A.Im = Im; - A.pt = pt; - A.sample_dt = median(diff(t)); - A.sample_dt_report = A.sample_dt; - A.ampEstimate_A = max(abs(Im)); - - meta = struct(); - if isfield(item, 'meta') - meta = item.meta; - end - [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); - A.pulse = pulse; - A.detectMode = pulse.method; - A.detectMsg = pulseMsg; - - if ~pulse.ok - A.message = pulseMsg; - A.logOnFailure = true; - return; - end - - V = computeVoltageTransientMetrics(t, Vf, pulse, A.delay_s); - A = mergeStructs(A, V); - - Q = computeInjectedCharge(t, Im, pulse, A.usedMeasuredCurrent); - A = mergeStructs(A, Q); - if ~Q.ok - A.message = Q.message; - return; - end - - if isfinite(A.area_cm2) && A.area_cm2 > 0 - A.CICc_mCcm2 = 1e3 * A.Qc_C / A.area_cm2; - A.CICa_mCcm2 = 1e3 * A.Qa_C / A.area_cm2; - A.CICt_mCcm2 = 1e3 * A.Qt_C / A.area_cm2; - else - A.CICc_mCcm2 = NaN; - A.CICa_mCcm2 = NaN; - A.CICt_mCcm2 = NaN; - end - - safety = checkWaterWindowSafety(A.Emc, A.Ema, A.cathLimit, A.anodLimit); - A = mergeStructs(A, safety); - - A.ok = true; - A.message = 'OK'; -end - -function opts = fillCICOptions(opts) - if ~isfield(opts, 'delay_s') - opts.delay_s = 10e-6; - end - if ~isfield(opts, 'cathLimit') - opts.cathLimit = -0.6; - end - if ~isfield(opts, 'anodLimit') - opts.anodLimit = 0.8; - end - if ~isfield(opts, 'areaOverride') - opts.areaOverride = ''; - end - if ~isfield(opts, 'area_cm2') - opts.area_cm2 = NaN; - end - if ~isfield(opts, 'pulseMode') - opts.pulseMode = 'Metadata first, then auto'; - end - if ~isfield(opts, 'usedMeasuredCurrent') - opts.usedMeasuredCurrent = true; - end -end - -function area = chooseArea(item, opts) - area = NaN; - if isfield(opts, 'areaOverride') - area = parsePositiveScalar(opts.areaOverride); - end - if ~isfinite(area) && isfield(opts, 'area_cm2') - area = parsePositiveScalar(opts.area_cm2); - end - if ~isfinite(area) && isfield(item, 'meta') && isfield(item.meta, 'area_cm2') ... - && isfinite(item.meta.area_cm2) && item.meta.area_cm2 > 0 - area = item.meta.area_cm2; - end -end - -function [curve, ok, msg] = mainCurve(item) - if isfield(item, 'curve') && ~isempty(item.curve) - curve = item.curve; - ok = true; - msg = sprintf('Using table: %s', curve.name); - elseif isfield(item, 'tables') - [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); - else - curve = struct(); - ok = false; - msg = 'Main transient table not found.'; - end -end - -function out = mergeStructs(out, in) - names = fieldnames(in); - for i = 1:numel(names) - out.(names{i}) = in.(names{i}); - end -end - -function V = computeVoltageTransientMetrics(t, Vf, pulse, delay_s) - V = struct(); - V.t_emc = pulse.cath_end + delay_s; - V.t_ema = pulse.anod_end + delay_s; - V.emc_idx = nearestIndex(t, V.t_emc); - V.ema_idx = nearestIndex(t, V.t_ema); - V.Emc = interp1Safe(t, Vf, V.t_emc); - V.Ema = interp1Safe(t, Vf, V.t_ema); - - V.Epre = medianInWindow(t, Vf, pulse.pre_start, pulse.pre_end); - V.Ebetween = medianInWindow(t, Vf, pulse.gap_start, pulse.gap_end); - V.Epost = medianInWindow(t, Vf, pulse.post_start, pulse.post_end); - [V.Eipp, V.baselineCathSource, V.baselineCathWindow] = chooseBaselineCandidate( ... - [V.Epre, V.Ebetween, V.Epost, 0], ... - {'pre-pulse median', 'interpulse median', 'post-pulse median', 'zero fallback'}, ... - [pulse.pre_start pulse.pre_end; pulse.gap_start pulse.gap_end; pulse.post_start pulse.post_end; NaN NaN]); - [V.Eipp_gap, V.baselineAnodSource, V.baselineAnodWindow] = chooseBaselineCandidate( ... - [V.Ebetween, V.Epre, V.Epost, V.Eipp], ... - {'interpulse median', 'pre-pulse median', 'post-pulse median', 'cathodic baseline fallback'}, ... - [pulse.gap_start pulse.gap_end; pulse.pre_start pulse.pre_end; pulse.post_start pulse.post_end; V.baselineCathWindow]); - - V.tc_s = max(0, pulse.cath_end - pulse.cath_start); - V.ta_s = max(0, pulse.anod_end - pulse.anod_start); - V.tip_s = max(0, pulse.anod_start - pulse.cath_end); - V.t_conset = pulse.cath_start + delay_s; - V.t_aonset = pulse.anod_start + delay_s; - V.Vc_on = interp1Safe(t, Vf, V.t_conset); - V.Va_on = interp1Safe(t, Vf, V.t_aonset); - V.Va_cath_mag = abs(V.Eipp - V.Vc_on); - V.Va_anod_mag = abs(V.Eipp_gap - V.Va_on); -end - -function Q = computeInjectedCharge(t, Im, pulse, useMeasuredCurrent) - if nargin < 4 - useMeasuredCurrent = true; - end - - Q = struct(); - cathMask = (t >= pulse.cath_start) & (t <= pulse.cath_end); - anodMask = (t >= pulse.anod_start) & (t <= pulse.anod_end); - Q.cathMask = cathMask; - Q.anodMask = anodMask; - - if sum(cathMask) < 2 || sum(anodMask) < 2 - Q.ok = false; - Q.message = 'Pulse windows too short after detection.'; - return; - end - - Q.Ic_est_A = median(Im(cathMask), 'omitnan'); - Q.Ia_est_A = median(Im(anodMask), 'omitnan'); - if ~isfinite(Q.Ic_est_A) - Q.Ic_est_A = pulse.Ic_nominal; - end - if ~isfinite(Q.Ia_est_A) - Q.Ia_est_A = pulse.Ia_nominal; - end - - if useMeasuredCurrent - Qc = abs(trapz(t(cathMask), Im(cathMask))); - Qa = abs(trapz(t(anodMask), Im(anodMask))); - else - Qc = abs(pulse.Ic_nominal * (pulse.cath_end - pulse.cath_start)); - Qa = abs(pulse.Ia_nominal * (pulse.anod_end - pulse.anod_start)); - end - - Q.Qc_C = Qc; - Q.Qa_C = Qa; - Q.Qt_C = Qc + Qa; - Q.ok = true; - Q.message = 'OK'; -end - -function safety = checkWaterWindowSafety(Emc, Ema, cathLimit, anodLimit) - safety = struct(); - safety.cathOK = Emc >= cathLimit; - safety.anodOK = Ema <= anodLimit; - safety.safe = safety.cathOK && safety.anodOK; - - if safety.safe - safety.limitSide = 'safe'; - elseif ~safety.cathOK && ~safety.anodOK - safety.limitSide = 'both exceeded'; - elseif ~safety.cathOK - safety.limitSide = 'cathodic exceeded'; - else - safety.limitSide = 'anodic exceeded'; - end -end - -%% App-local table/export helpers -function [C, columnNames] = buildBatchTableData(items, unitLabel) -%BUILDBATCHTABLEDATA Build legacy CIC batch uitable data. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitLabel] = displayScale(unitLabel); - columnNames = {'File', 'Amp(A)', 'Emc(V)', 'Ema(V)', ... - ['Qc(' unitLabel ')'], ['Qa(' unitLabel ')'], ['Qtot(' unitLabel ')'], 'Safe'}; - - C = cell(numel(items), 8); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.ampEstimate_A; - C{i, 3} = A.Emc; - C{i, 4} = A.Ema; - C{i, 5} = scale * A.CICc_mCcm2; - C{i, 6} = scale * A.CICa_mCcm2; - C{i, 7} = scale * A.CICt_mCcm2; - C{i, 8} = ternary(A.safe, 'safe', A.limitSide); - end -end - -function T = buildResultsTable(items, unitLabel) -%BUILDRESULTSTABLE Build legacy CIC CSV result table. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitSuffix] = displayScaleSuffix(unitLabel); - - file = cell(numel(items), 1); - amp_A = NaN(numel(items), 1); - Emc_V = NaN(numel(items), 1); - Ema_V = NaN(numel(items), 1); - Qc_C = NaN(numel(items), 1); - Qa_C = NaN(numel(items), 1); - Qt_C = NaN(numel(items), 1); - CICc = NaN(numel(items), 1); - CICa = NaN(numel(items), 1); - CICt = NaN(numel(items), 1); - safe = zeros(numel(items), 1); - detection = cell(numel(items), 1); - - for i = 1:numel(items) - item = items(i); - file{i} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - detection{i} = 'failed'; - continue; - end - - amp_A(i) = A.ampEstimate_A; - Emc_V(i) = A.Emc; - Ema_V(i) = A.Ema; - Qc_C(i) = A.Qc_C; - Qa_C(i) = A.Qa_C; - Qt_C(i) = A.Qt_C; - CICc(i) = scale * A.CICc_mCcm2; - CICa(i) = scale * A.CICa_mCcm2; - CICt(i) = scale * A.CICt_mCcm2; - safe(i) = A.safe; - detection{i} = A.detectMode; - end - - T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, safe, detection, ... - 'VariableNames', {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... - ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], 'Safe', 'Detection'}); -end - -function [ok, msg] = writeResultsCSV(items, filepath, unitLabel) -%WRITERESULTSCSV Write CIC results in legacy CSV format. - - if nargin < 3 - unitLabel = 'mC/cm^2'; - end - - ok = true; - msg = ''; - - fid = fopen(filepath, 'w'); - if fid < 0 - ok = false; - msg = 'Could not open file for writing.'; - if nargout == 0 - error(msg); - end - return; - end - cleaner = onCleanup(@() fclose(fid)); - - try - T = buildResultsTable(items, unitLabel); - names = T.Properties.VariableNames; - fprintf(fid, 'File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,%s,%s,%s,Safe,Detection\n', ... - names{8}, names{9}, names{10}); - for i = 1:height(T) - if strcmp(T.Detection{i}, 'failed') - fprintf(fid, '"%s",,,,,,,,,,0,"failed"\n', T.File{i}); - else - fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%d,"%s"\n', ... - T.File{i}, T.Amp_A(i), T.Emc_V(i), T.Ema_V(i), T.Qc_C(i), T.Qa_C(i), T.Qt_C(i), ... - T.(names{8})(i), T.(names{9})(i), T.(names{10})(i), T.Safe(i), T.Detection{i}); - end - end - catch ME - ok = false; - msg = ME.message; - if nargout == 0 - rethrow(ME); - end - end -end - -%% App-local plotting helpers -function [v, sourceLabel, window] = chooseBaselineCandidate(candidates, sourceLabels, windows) - v = NaN; - sourceLabel = 'unavailable'; - window = [NaN NaN]; - for k = 1:numel(candidates) - if isfinite(candidates(k)) - v = candidates(k); - sourceLabel = sourceLabels{k}; - if size(windows, 1) >= k - window = windows(k, :); - end - return; - end - end -end - -function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end -end - -function [scale, unitSuffix] = displayScaleSuffix(unitLabel) - [scale, unitLabel] = displayScale(unitLabel); - unitSuffix = regexprep(unitLabel, '[\^/]', ''); -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end - -function out = formatChargeDensity(Q_C, cic_mCcm2, unitLabel) - if isfinite(cic_mCcm2) - switch unitLabel - case 'uC/cm^2' - cic = 1e3 * cic_mCcm2; - otherwise - cic = cic_mCcm2; - unitLabel = 'mC/cm^2'; - end - out = sprintf('%.6e C | %.6f %s', Q_C, cic, unitLabel); - else - out = sprintf('%.6e C | area unavailable', Q_C); - end -end - -function s = formatMaybeNum(v, fmt) - if isfinite(v) - s = sprintf(fmt, v); - else - s = 'NaN'; - end -end - -function txt = ternary(cond, a, b) - if cond - txt = a; - else - txt = b; - end -end - -function shadeWindow(ax, x1, x2, color) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - yl = ylim(ax); - patch(ax,[x1 x2 x2 x1],[yl(1) yl(1) yl(2) yl(2)],color, ... - 'FaceAlpha',0.25,'EdgeColor','none','HandleVisibility','off'); - uistack(findobj(ax,'Type','patch'),'bottom'); -end - -function labelPulseCharge(ax, x1, x2, Q, tagText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - xm = 0.5 * (x1 + x2); - yl = ylim(ax); - y0 = yl(1) + 0.90 * (yl(2) - yl(1)); - text(ax, xm, y0, sprintf('%s = %.3e C', tagText, Q), ... - 'HorizontalAlignment','center','VerticalAlignment','middle', ... - 'BackgroundColor','w','Margin',2); -end - -function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.07*dy; - yMid = yl(1) + 0.55*dy; - yLow = yl(1) + 0.18*dy; - - if strcmp(xChoice,'Sample #') - cOnX = interp1Safe(A.t, A.pt, A.t_conset); - aOnX = interp1Safe(A.t, A.pt, A.t_aonset); - cathBase1 = interp1Safe(A.t, A.pt, A.baselineCathWindow(1)); - cathBase2 = interp1Safe(A.t, A.pt, A.baselineCathWindow(2)); - anodBase1 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(1)); - anodBase2 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(2)); - else - cOnX = A.t_conset; - aOnX = A.t_aonset; - cathBase1 = A.baselineCathWindow(1); - cathBase2 = A.baselineCathWindow(2); - anodBase1 = A.baselineAnodWindow(1); - anodBase2 = A.baselineAnodWindow(2); - end - - plot(ax, emcX, A.Emc, 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); - plot(ax, emaX, A.Ema, 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); - plot(ax, cOnX, A.Vc_on, 's', 'MarkerFaceColor',[0.2 0.6 1.0], 'MarkerEdgeColor','k', 'MarkerSize',6); - plot(ax, aOnX, A.Va_on, 's', 'MarkerFaceColor',[1.0 0.6 0.2], 'MarkerEdgeColor','k', 'MarkerSize',6); - - if isfinite(A.Eipp) - drawBaselineSegment(ax, cathBase1, cathBase2, A.Eipp, [0.25 0.25 0.25], ... - sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), 'bottom'); - end - if isfinite(A.Eipp_gap) - drawBaselineSegment(ax, anodBase1, anodBase2, A.Eipp_gap, [0.45 0.45 0.45], ... - sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), 'top'); - end - - if isfinite(A.Eipp) && isfinite(A.Vc_on) - plot(ax, [cOnX cOnX], [A.Eipp A.Vc_on], '--', 'Color',[0.2 0.6 1.0], 'LineWidth',1.0); - text(ax, cOnX, 0.5*(A.Eipp + A.Vc_on), sprintf(' Va(c)=%.3f V', A.Va_cath_mag), ... - 'Color',[0.15 0.45 0.8], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); - end - if isfinite(A.Eipp_gap) && isfinite(A.Va_on) - plot(ax, [aOnX aOnX], [A.Eipp_gap A.Va_on], '--', 'Color',[0.95 0.55 0.2], 'LineWidth',1.0); - text(ax, aOnX, 0.5*(A.Eipp_gap + A.Va_on), sprintf(' Va(a)=%.3f V', A.Va_anod_mag), ... - 'Color',[0.75 0.35 0.05], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); - end - - text(ax, emcX, A.Emc, sprintf(' Emc = %.4f V', A.Emc), 'VerticalAlignment','bottom', 'Color',[0.1 0.5 0.1]); - text(ax, emaX, A.Ema, sprintf(' Ema = %.4f V', A.Ema), 'VerticalAlignment','top', 'Color',[0.6 0.4 0]); - - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop - 0.06*dy, sprintf('ta = %.3f ms', 1e3*A.ta_s)); - if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yLow, sprintf('tip = %.1f us', 1e6*A.tip_s)); - end - yline(ax, yMid, ':', 'Color',[0.8 0.8 0.8], 'HandleVisibility','off'); -end - -function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) - plot(ax, emcX, interp1Safe(chooseX(A,xChoice), A.Im, emcX), 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); - plot(ax, emaX, interp1Safe(chooseX(A,xChoice), A.Im, emaX), 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); - - plot(ax, [cathStartX cathEndX], [A.Ic_est_A A.Ic_est_A], '--', 'Color',[0.1 0.45 0.8], 'LineWidth',1.3); - plot(ax, [anodStartX anodEndX], [A.Ia_est_A A.Ia_est_A], '--', 'Color',[0.85 0.45 0.1], 'LineWidth',1.3); - text(ax, cathEndX, A.Ic_est_A, sprintf(' ic = %.3f mA', 1e3*A.Ic_est_A), 'Color',[0.1 0.35 0.75], 'VerticalAlignment','bottom'); - text(ax, anodEndX, A.Ia_est_A, sprintf(' ia = %.3f mA', 1e3*A.Ia_est_A), 'Color',[0.7 0.32 0.05], 'VerticalAlignment','top'); - - labelPulseCharge(ax, cathStartX, cathEndX, A.Qc_C, 'Qc'); - labelPulseCharge(ax, anodStartX, anodEndX, A.Qa_C, 'Qa'); - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08*dy; - yMid = yl(2) - 0.16*dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop, sprintf('ta = %.3f ms', 1e3*A.ta_s)); - if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yMid, sprintf('tip = %.1f us', 1e6*A.tip_s)); - end -end - -function drawDurationBracket(ax, x1, x2, y, labelText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - yl = ylim(ax); - h = 0.025 * (yl(2) - yl(1)); - plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, 0.5*(x1+x2), y + 1.4*h, labelText, 'HorizontalAlignment','center', ... - 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1); -end - -function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) - if ~isfinite(y) - return; - end - if isfinite(x1) && isfinite(x2) && x2 > x1 - xStart = x1; - xEnd = x2; - else - xl = xlim(ax); - xStart = xl(1) + 0.04 * (xl(2) - xl(1)); - xEnd = xStart + 0.18 * (xl(2) - xl(1)); - end - plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); - text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... - 'BackgroundColor','w', 'Margin',1, 'Interpreter','none'); -end - -function addBaselineYLines(ax, A) - if isfinite(A.Eipp) - yline(ax, A.Eipp, '--', ... - sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), ... - 'Color',[0.20 0.20 0.20], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','bottom'); - end - if isfinite(A.Eipp_gap) - yline(ax, A.Eipp_gap, '--', ... - sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), ... - 'Color',[0.40 0.40 0.40], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','top'); - end -end - -function x = chooseX(A, xChoice) - if strcmp(xChoice, 'Sample #') - x = A.pt; - else - x = A.t; - end -end - -function v = chooseFinite(varargin) - v = NaN; - for k = 1:nargin - if isfinite(varargin{k}) - v = varargin{k}; - return; - end - end -end - -function s = shortBaselineSource(sourceLabel) - switch sourceLabel - case 'pre-pulse median' - s = 'pre'; - case 'interpulse median' - s = 'gap'; - case 'post-pulse median' - s = 'post'; - case 'zero fallback' - s = '0 V fallback'; - case 'cathodic baseline fallback' - s = 'cath fallback'; - otherwise - s = sourceLabel; - end -end - -function q = parsePositiveScalar(x) - if isnumeric(x) - q = x; - else - x = strtrim(char(x)); - if isempty(x) - q = NaN; - return; - end - q = str2double(x); - end - - if ~isscalar(q) || ~isfinite(q) || q <= 0 - q = NaN; - end -end - -function v = interp1Safe(x, y, xq) - if numel(x) < 2 || any(~isfinite([x(:); y(:)])) - v = NaN; - return; - end - - try - v = interp1(x, y, xq, 'linear', 'extrap'); - catch - idx = nearestIndex(x, xq); - v = y(idx); - end -end - -function idx = nearestIndex(x, xq) - [~, idx] = min(abs(x - xq)); -end - -function m = medianInWindow(t, y, t1, t2) - if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 - m = NaN; - return; - end - - mask = t >= t1 & t <= t2; - if ~any(mask) - m = NaN; - else - m = median(y(mask), 'omitnan'); - end -end diff --git a/apps/electrochem/private/runChronoOverlayApp.m b/apps/electrochem/private/runChronoOverlayApp.m index a1c5873..ff3849b 100644 --- a/apps/electrochem/private/runChronoOverlayApp.m +++ b/apps/electrochem/private/runChronoOverlayApp.m @@ -204,7 +204,7 @@ function refreshFileList() function refreshPlots() if isempty(S.items) - plotVTIT(axV, axI, struct([]), plotOptions()); + chronoOverlayWorkflow("plotVTIT", axV, axI, struct([]), plotOptions()); return; end @@ -215,7 +215,7 @@ function refreshPlots() return; end - plotVTIT(axV, axI, items, plotOptions()); + chronoOverlayWorkflow("plotVTIT", axV, axI, items, plotOptions()); end function onExportCSV(~, ~) @@ -254,265 +254,3 @@ function addLog(msg) debugLog.append(msg); end end - -%% App-local analysis -function [item, msg] = alignByPulseGap(item) - t = chronoTime(item); - if isempty(t) - error('Chrono item has no time vector.'); - end - - pulseMsg = ''; - if isfield(item, 'pulseMessage') - pulseMsg = item.pulseMessage; - elseif isfield(item, 'pulse') && isfield(item.pulse, 'message') - pulseMsg = item.pulse.message; - end - - pulse = emptyPulse(); - if isfield(item, 'pulse') - pulse = item.pulse; - end - - if isfield(item, 'name') - itemName = item.name; - else - itemName = ''; - end - - if isfield(pulse, 'ok') && pulse.ok - alignTime = 0.5 * (pulse.gap_start + pulse.gap_end); - if isfinite(alignTime) - item.alignTime = alignTime; - item.tAligned = t - alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: aligned to cathodic/anodic blank center at %.9g s (gap %.9g to %.9g s, %s).', ... - itemName, alignTime, pulse.gap_start, pulse.gap_end, pulse.method); - return; - end - - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: blank center not found, fallback to first sample (%s).', itemName, pulseMsg); - return; - end - - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: pulse gap not found, fallback to first sample (%s).', itemName, pulseMsg); -end - -%% App-local export -function T = buildOverlayExportTable(items) - timeUnion = []; - for i = 1:numel(items) - timeUnion = [timeUnion; chronoAlignedTime(items(i))]; %#ok - end - timeUnion = unique(timeUnion); - timeUnion = sort(timeUnion); - - T = table(timeUnion, 'VariableNames', {'TimeGapCenterAligned_s'}); - for i = 1:numel(items) - safeName = sanitizeFieldName(items(i).name); - vName = ['V_' safeName]; - iName = ['I_' safeName]; - - tAligned = chronoAlignedTime(items(i)); - Vf = chronoVoltage(items(i)); - Im = chronoCurrent(items(i)); - if numel(tAligned) >= 2 - vData = interp1(tAligned, Vf, timeUnion, 'linear', NaN); - iData = interp1(tAligned, Im, timeUnion, 'linear', NaN); - else - vData = NaN(size(timeUnion)); - iData = NaN(size(timeUnion)); - end - - T.(vName) = vData; - T.(iName) = iData; - end -end - -%% App-local plotting -function plotVTIT(axV, axI, items, opts) - if nargin < 4 - opts = struct(); - end - if ~isfield(opts, 'xAxis') - opts.xAxis = 'Time (s)'; - end - if ~isfield(opts, 'lineWidth') - opts.lineWidth = 1.3; - end - if ~isfield(opts, 'showGrid') - opts.showGrid = true; - end - if ~isfield(opts, 'showLegend') - opts.showLegend = true; - end - - cla(axV); - cla(axI); - - if isempty(items) - title(axV, 'Voltage'); - title(axI, 'Current'); - xlabel(axV, 'Blank-Center Aligned Time (s)'); - xlabel(axI, 'Blank-Center Aligned Time (s)'); - ylabel(axV, 'Vf (V)'); - ylabel(axI, 'Im (A)'); - return; - end - - cmap = lines(numel(items)); - hold(axV, 'on'); - hold(axI, 'on'); - - labels = cell(1, numel(items)); - for k = 1:numel(items) - item = items(k); - x = chooseX(item, opts.xAxis); - plot(axV, x, chronoVoltage(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); - plot(axI, x, chronoCurrent(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); - labels{k} = char(item.name); - end - - hold(axV, 'off'); - hold(axI, 'off'); - - xlabelText = axisLabel(opts.xAxis); - xlabel(axV, xlabelText); - xlabel(axI, xlabelText); - ylabel(axV, 'Vf (V)'); - ylabel(axI, 'Im (A)'); - title(axV, sprintf('Voltage Overlay (%d file%s)', numel(items), pluralS(numel(items)))); - title(axI, sprintf('Current Overlay (%d file%s)', numel(items), pluralS(numel(items)))); - - if opts.showGrid - grid(axV, 'on'); - grid(axI, 'on'); - else - grid(axV, 'off'); - grid(axI, 'off'); - end - - if opts.showLegend - legend(axV, labels, 'Interpreter', 'none', 'Location', 'best'); - legend(axI, labels, 'Interpreter', 'none', 'Location', 'best'); - else - legend(axV, 'off'); - legend(axI, 'off'); - end -end - -%% Small app-local utilities -function t = chronoTime(item) - if isfield(item, 't') && ~isempty(item.t) - t = item.t; - elseif isfield(item, 't_s') && ~isempty(item.t_s) - t = item.t_s; - else - t = []; - end - t = t(:); -end - -function t = chronoAlignedTime(item) - if isfield(item, 'tAligned') && ~isempty(item.tAligned) - t = item.tAligned(:); - elseif isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) - t = item.tAligned_s(:); - else - t = []; - end -end - -function v = chronoVoltage(item) - if isfield(item, 'Vf') && ~isempty(item.Vf) - v = item.Vf(:); - elseif isfield(item, 'Vf_V') && ~isempty(item.Vf_V) - v = item.Vf_V(:); - else - v = []; - end -end - -function i = chronoCurrent(item) - if isfield(item, 'Im') && ~isempty(item.Im) - i = item.Im(:); - elseif isfield(item, 'Im_A') && ~isempty(item.Im_A) - i = item.Im_A(:); - else - i = []; - end -end - -function x = chooseX(item, mode) - switch mode - case 'Time (ms)' - x = 1e3 * chronoAlignedTime(item); - case 'Sample #' - x = samplePoint(item); - otherwise - x = chronoAlignedTime(item); - end -end - -function pt = samplePoint(item) - if isfield(item, 'pt') && ~isempty(item.pt) - pt = item.pt(:); - else - pt = (0:numel(chronoAlignedTime(item))-1).'; - end -end - -function txt = axisLabel(mode) - switch mode - case 'Time (ms)' - txt = 'Blank-Center Aligned Time (ms)'; - case 'Sample #' - txt = 'Sample #'; - otherwise - txt = 'Blank-Center Aligned Time (s)'; - end -end - -function s = pluralS(n) - if n == 1 - s = ''; - else - s = 's'; - end -end - -function out = sanitizeFieldName(txt) - out = matlab.lang.makeValidName(txt); -end - -function pulse = emptyPulse() - pulse = struct( ... - 'ok', false, ... - 'method', '-', ... - 'message', '', ... - 'cath_start', NaN, ... - 'cath_end', NaN, ... - 'anod_start', NaN, ... - 'anod_end', NaN, ... - 'Ic_nominal', NaN, ... - 'Ia_nominal', NaN, ... - 'pre_start', NaN, ... - 'pre_end', NaN, ... - 'gap_start', NaN, ... - 'gap_end', NaN, ... - 'post_start', NaN, ... - 'post_end', NaN); - - pulse.cath = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.gap = struct('start_s', NaN, 'end_s', NaN, 'center_s', NaN); -end diff --git a/apps/electrochem/private/runVTResistanceApp.m b/apps/electrochem/private/runVTResistanceApp.m index 745474c..9ecd0ee 100644 --- a/apps/electrochem/private/runVTResistanceApp.m +++ b/apps/electrochem/private/runVTResistanceApp.m @@ -312,8 +312,8 @@ function refreshResultsSummary() S.txtAnodIV.Value = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', A.Ia_est_A, A.Va_ss_V, A.dVa_V); S.txtCathBase.Value = sprintf('%.6f V', A.Vc_baseline_V); S.txtAnodBase.Value = sprintf('%.6f V', A.Va_baseline_V); - S.txtCathBaseWin.Value = formatDurationUs(A.cathBaselineWindow_s); - S.txtAnodBaseWin.Value = formatDurationUs(A.anodBaselineWindow_s); + S.txtCathBaseWin.Value = vtResistanceWorkflow("formatDurationUs", A.cathBaselineWindow_s); + S.txtAnodBaseWin.Value = vtResistanceWorkflow("formatDurationUs", A.anodBaselineWindow_s); S.txtCathR.Value = sprintf('%.6g ohm (signed %.6g)', A.Rc_abs_ohm, A.Rc_ohm); S.txtAnodR.Value = sprintf('%.6g ohm (signed %.6g)', A.Ra_abs_ohm, A.Ra_ohm); S.txtAvgR.Value = sprintf('%.6g ohm', A.Ravg_abs_ohm); @@ -361,18 +361,18 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) if strcmp(xChoice,'Sample #') x = A.pt; xlab = 'Sample #'; - cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); - cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); - anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); - anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); - cathBaseStartX = interp1Safe(A.t, A.pt, A.pulse.pre_start); - cathBaseEndX = interp1Safe(A.t, A.pt, A.pulse.pre_end); - anodBaseStartX = interp1Safe(A.t, A.pt, A.anodBaselineStart); - anodBaseEndX = interp1Safe(A.t, A.pt, A.anodBaselineEnd); - cSteadyStartX = interp1Safe(A.t, A.pt, A.cathSteadyStart); - cSteadyEndX = interp1Safe(A.t, A.pt, A.cathSteadyEnd); - aSteadyStartX = interp1Safe(A.t, A.pt, A.anodSteadyStart); - aSteadyEndX = interp1Safe(A.t, A.pt, A.anodSteadyEnd); + cathStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.cath_start); + cathEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.cath_end); + anodStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.anod_start); + anodEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.anod_end); + cathBaseStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.pre_start); + cathBaseEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.pulse.pre_end); + anodBaseStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.anodBaselineStart); + anodBaseEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.anodBaselineEnd); + cSteadyStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.cathSteadyStart); + cSteadyEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.cathSteadyEnd); + aSteadyStartX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.anodSteadyStart); + aSteadyEndX = vtResistanceWorkflow("interp1Safe", A.t, A.pt, A.anodSteadyEnd); else x = A.t; xlab = 'Time (s)'; @@ -403,10 +403,10 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) end if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.90 0.95 1.00], 0.12); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.94 0.88], 0.12); - shadeWindow(ax, cSteadyStartX, cSteadyEndX, [0.65 0.82 1.00], 0.22); - shadeWindow(ax, aSteadyStartX, aSteadyEndX, [1.00 0.75 0.55], 0.22); + vtResistanceWorkflow("shadeWindow", ax, cathStartX, cathEndX, [0.90 0.95 1.00], 0.12); + vtResistanceWorkflow("shadeWindow", ax, anodStartX, anodEndX, [1.00 0.94 0.88], 0.12); + vtResistanceWorkflow("shadeWindow", ax, cSteadyStartX, cSteadyEndX, [0.65 0.82 1.00], 0.22); + vtResistanceWorkflow("shadeWindow", ax, aSteadyStartX, aSteadyEndX, [1.00 0.75 0.55], 0.22); end if cbShowMarkers.Value xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); @@ -414,10 +414,10 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); if startsWith(yChoice,'VT') - addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... + vtResistanceWorkflow("addResistanceVTAnnotations", ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX); else - addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... + vtResistanceWorkflow("addResistanceITAnnotations", ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... cathStartX, cathEndX, anodStartX, anodEndX); end end @@ -426,7 +426,11 @@ function plotOneAxis(ax, A, xChoice, yChoice, showGrid) title(ax, ttl, 'Interpreter','none'); xlabel(ax, xlab); ylabel(ax, ylab); - grid(ax, ternary(showGrid,'on','off')); + if showGrid + grid(ax, 'on'); + else + grid(ax, 'off'); + end end function nm = itName() @@ -481,512 +485,3 @@ function addLog(msg) end end - -%% App-local analysis -function A = computeResistance(item, opts) -%COMPUTERESISTANCE Compute VT resistance metrics for the VT app. - - if nargin < 2 - opts = struct(); - end - opts = fillResistanceOptions(opts); - - A = struct(); - A.ok = false; - A.message = ''; - A.windowMode = opts.windowMode; - A.voltageMode = opts.voltageMode; - A.logOnFailure = false; - - [curve, okCurve, msgCurve] = mainCurve(item); - if ~okCurve - A.message = msgCurve; - A.logOnFailure = true; - return; - end - - t = labkit.dta.getColumn(curve, 'T'); - Vf = labkit.dta.getColumn(curve, 'Vf'); - Im = labkit.dta.getColumn(curve, 'Im'); - pt = labkit.dta.getColumn(curve, 'Pt'); - if isempty(pt) - pt = (0:numel(t)-1).'; - end - - valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); - t = t(valid); - Vf = Vf(valid); - Im = Im(valid); - pt = pt(valid); - if numel(t) < 5 - A.message = 'Not enough valid T/Vf/Im points.'; - return; - end - - A.t = t; - A.Vf = Vf; - A.Im = Im; - A.pt = pt; - - meta = struct(); - if isfield(item, 'meta') - meta = item.meta; - end - [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); - A.pulse = pulse; - A.detectMode = pulse.method; - A.detectMsg = pulseMsg; - if ~pulse.ok - A.message = pulseMsg; - A.logOnFailure = true; - return; - end - - [cStart, cEnd] = selectSteadyWindow(pulse.cath_start, pulse.cath_end, A.windowMode); - [aStart, aEnd] = selectSteadyWindow(pulse.anod_start, pulse.anod_end, A.windowMode); - cathMask = t >= cStart & t <= cEnd; - anodMask = t >= aStart & t <= aEnd; - if nnz(cathMask) < 2 || nnz(anodMask) < 2 - A.message = 'Steady windows are too short after pulse detection.'; - return; - end - - A.cathMask = cathMask; - A.anodMask = anodMask; - A.cathSteadyStart = cStart; - A.cathSteadyEnd = cEnd; - A.anodSteadyStart = aStart; - A.anodSteadyEnd = aEnd; - - A.Ic_est_A = median(Im(cathMask), 'omitnan'); - A.Ia_est_A = median(Im(anodMask), 'omitnan'); - A.Vc_ss_V = median(Vf(cathMask), 'omitnan'); - A.Va_ss_V = median(Vf(anodMask), 'omitnan'); - - A.cathBaselineStart = pulse.pre_start; - A.cathBaselineEnd = pulse.pre_end; - A.anodBaselineStart = pulse.post_start; - A.anodBaselineEnd = pulse.post_end; - [A.Vc_baseline_V, A.cathBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.pre_start, pulse.pre_end, 0); - [A.Va_baseline_V, A.anodBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.post_start, pulse.post_end, chooseFinite(A.Vc_baseline_V, 0)); - - A.dVc_V = A.Vc_ss_V - A.Vc_baseline_V; - A.dVa_V = A.Va_ss_V - A.Va_baseline_V; - A.Rc_raw_ohm = safeDivide(A.Vc_ss_V, A.Ic_est_A); - A.Ra_raw_ohm = safeDivide(A.Va_ss_V, A.Ia_est_A); - A.Rc_dV_ohm = safeDivide(A.dVc_V, A.Ic_est_A); - A.Ra_dV_ohm = safeDivide(A.dVa_V, A.Ia_est_A); - - if strcmp(A.voltageMode, 'Raw Vf/I') - A.Rc_ohm = A.Rc_raw_ohm; - A.Ra_ohm = A.Ra_raw_ohm; - else - A.Rc_ohm = A.Rc_dV_ohm; - A.Ra_ohm = A.Ra_dV_ohm; - end - A.Rc_abs_ohm = abs(A.Rc_ohm); - A.Ra_abs_ohm = abs(A.Ra_ohm); - A.Ravg_abs_ohm = mean([A.Rc_abs_ohm, A.Ra_abs_ohm], 'omitnan'); - - A.ok = isfinite(A.Ravg_abs_ohm); - if A.ok - A.message = 'OK'; - else - A.message = 'Resistance could not be computed; check current and pulse detection.'; - A.logOnFailure = true; - end -end - -function opts = fillResistanceOptions(opts) - if ~isfield(opts, 'windowMode') - opts.windowMode = 'Full pulse median'; - end - if ~isfield(opts, 'voltageMode') - opts.voltageMode = 'Baseline-corrected dV/I'; - end - if ~isfield(opts, 'pulseMode') - opts.pulseMode = 'Metadata first, then auto'; - end -end - -%% App-local table/export helpers -function C = buildBatchTableData(items) -%BUILDBATCHTABLEDATA Build VT resistance uitable data. - - C = cell(numel(items), 9); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = NaN; - C{i, 9} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.Ic_est_A; - C{i, 3} = A.Ia_est_A; - C{i, 4} = A.Vc_ss_V; - C{i, 5} = A.Va_ss_V; - C{i, 6} = A.Rc_abs_ohm; - C{i, 7} = A.Ra_abs_ohm; - C{i, 8} = A.Ravg_abs_ohm; - C{i, 9} = A.detectMode; - end -end - -function T = buildResultsTable(items) -%BUILDRESULTSTABLE Build VT resistance CSV result table. - - file = cell(numel(items), 1); - Ic_A = NaN(numel(items), 1); - Ia_A = NaN(numel(items), 1); - Vc_ss_V = NaN(numel(items), 1); - Va_ss_V = NaN(numel(items), 1); - Vc_baseline_V = NaN(numel(items), 1); - Va_baseline_V = NaN(numel(items), 1); - dVc_V = NaN(numel(items), 1); - dVa_V = NaN(numel(items), 1); - Rc_bc_ohm = NaN(numel(items), 1); - Ra_bc_ohm = NaN(numel(items), 1); - Ravg_bc_ohm = NaN(numel(items), 1); - windowMode = cell(numel(items), 1); - detection = cell(numel(items), 1); - status = cell(numel(items), 1); - - for i = 1:numel(items) - item = items(i); - file{i} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - windowMode{i} = ''; - detection{i} = 'failed'; - status{i} = analysisMessage(A); - continue; - end - - Ic_A(i) = A.Ic_est_A; - Ia_A(i) = A.Ia_est_A; - Vc_ss_V(i) = A.Vc_ss_V; - Va_ss_V(i) = A.Va_ss_V; - Vc_baseline_V(i) = A.Vc_baseline_V; - Va_baseline_V(i) = A.Va_baseline_V; - dVc_V(i) = A.dVc_V; - dVa_V(i) = A.dVa_V; - Rc_bc_ohm(i) = abs(A.Rc_dV_ohm); - Ra_bc_ohm(i) = abs(A.Ra_dV_ohm); - Ravg_bc_ohm(i) = mean([Rc_bc_ohm(i), Ra_bc_ohm(i)], 'omitnan'); - windowMode{i} = A.windowMode; - detection{i} = A.detectMode; - status{i} = A.message; - end - - T = table(file, Ic_A, Ia_A, Vc_ss_V, Va_ss_V, Vc_baseline_V, Va_baseline_V, ... - dVc_V, dVa_V, Rc_bc_ohm, Ra_bc_ohm, Ravg_bc_ohm, windowMode, detection, status, ... - 'VariableNames', {'File', 'Ic_A', 'Ia_A', 'Vc_ss_V', 'Va_ss_V', ... - 'Vc_baseline_V', 'Va_baseline_V', 'dVc_V', 'dVa_V', 'Rc_bc_ohm', ... - 'Ra_bc_ohm', 'Ravg_bc_ohm', 'WindowMode', 'Detection', 'Status'}); -end - -function [ok, msg] = writeResultsCSV(items, filepath) -%WRITERESULTSCSV Write VT resistance results in legacy CSV format. - - ok = true; - msg = ''; - - fid = fopen(filepath, 'w'); - if fid < 0 - ok = false; - msg = 'Could not open file for writing.'; - if nargout == 0 - error(msg); - end - return; - end - cleaner = onCleanup(@() fclose(fid)); - - try - T = buildResultsTable(items); - fprintf(fid, 'File,Ic_A,Ia_A,Vc_ss_V,Va_ss_V,Vc_baseline_V,Va_baseline_V,dVc_V,dVa_V,Rc_bc_ohm,Ra_bc_ohm,Ravg_bc_ohm,WindowMode,Detection,Status\n'); - for i = 1:height(T) - fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,"%s","%s","%s"\n', ... - csvEscape(T.File{i}), ... - T.Ic_A(i), T.Ia_A(i), T.Vc_ss_V(i), T.Va_ss_V(i), ... - T.Vc_baseline_V(i), T.Va_baseline_V(i), T.dVc_V(i), T.dVa_V(i), ... - T.Rc_bc_ohm(i), T.Ra_bc_ohm(i), T.Ravg_bc_ohm(i), ... - csvEscape(T.WindowMode{i}), ... - csvEscape(T.Detection{i}), ... - csvEscape(T.Status{i})); - end - catch ME - ok = false; - msg = ME.message; - if nargout == 0 - rethrow(ME); - end - end -end - -%% App-local plotting helpers -function [curve, ok, msg] = mainCurve(item) - if isfield(item, 'curve') && ~isempty(item.curve) - curve = item.curve; - ok = true; - msg = sprintf('Using table: %s', curve.name); - elseif isfield(item, 'tables') - [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); - else - curve = struct(); - ok = false; - msg = 'Main transient table not found.'; - end -end - -function q = safeDivide(a, b) - if ~isscalar(a) || ~isscalar(b) || ~isfinite(a) || ~isfinite(b) || abs(b) < eps - q = NaN; - else - q = a / b; - end -end - -function v = chooseFinite(varargin) - v = NaN; - for k = 1:nargin - x = varargin{k}; - if isscalar(x) && isfinite(x) - v = x; - return; - end - end -end - -function [t1, t2] = selectSteadyWindow(p1, p2, modeText) - t1 = p1; - t2 = p2; - if strcmp(modeText, 'Center 60% median') && isfinite(p1) && isfinite(p2) && p2 > p1 - dt = p2 - p1; - t1 = p1 + 0.20 * dt; - t2 = p1 + 0.80 * dt; - end -end - -function [v, window_s] = estimateBaseline(t, y, t1, t2, fallbackValue) - if nargin < 5 - fallbackValue = NaN; - end - - v = medianInWindow(t, y, t1, t2); - if ~isfinite(v) - v = fallbackValue; - end - window_s = max(0, t2 - t1); -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end - -function msg = analysisMessage(A) - msg = ''; - if ~isempty(A) && isfield(A, 'message') - msg = A.message; - end -end - -function out = ternary(cond, a, b) - if cond - out = a; - else - out = b; - end -end - -function shadeWindow(ax, x1, x2, color, alphaVal) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - yl = ylim(ax); - if any(~isfinite(yl)) || yl(1) == yl(2) - return; - end - p = patch(ax, [x1 x2 x2 x1], [yl(1) yl(1) yl(2) yl(2)], color, ... - 'FaceAlpha',alphaVal,'EdgeColor','none','HandleVisibility','off'); - uistack(p,'bottom'); -end - -function addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... - cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX) - cSteadyMidX = midpointFinite(cSteadyStartX, cSteadyEndX); - aSteadyMidX = midpointFinite(aSteadyStartX, aSteadyEndX); - - drawBaselineSegment(ax, cathBaseStartX, cathBaseEndX, A.Vc_baseline_V, [0.20 0.20 0.20], ... - sprintf('Cath baseline = %.4f V', A.Vc_baseline_V), 'bottom'); - drawBaselineSegment(ax, anodBaseStartX, anodBaseEndX, A.Va_baseline_V, [0.35 0.35 0.35], ... - sprintf('Anod baseline = %.4f V', A.Va_baseline_V), 'top'); - - drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Vc_ss_V, [0.10 0.35 0.80], '--'); - drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Va_ss_V, [0.80 0.35 0.10], '--'); - - plot(ax, cSteadyEndX, A.Vc_ss_V, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - plot(ax, aSteadyEndX, A.Va_ss_V, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - - text(ax, cSteadyEndX, A.Vc_ss_V, sprintf(' Cath steady V = %.4f V', A.Vc_ss_V), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); - text(ax, aSteadyEndX, A.Va_ss_V, sprintf(' Anod steady V = %.4f V', A.Va_ss_V), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); - - if isfinite(cSteadyMidX) && isfinite(A.Vc_baseline_V) && isfinite(A.Vc_ss_V) - plot(ax, [cSteadyMidX cSteadyMidX], [A.Vc_baseline_V A.Vc_ss_V], '--', ... - 'Color',[0.10 0.35 0.80], 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, cSteadyMidX, 0.5*(A.Vc_baseline_V + A.Vc_ss_V), sprintf(' Cath dV = %.4f V', A.dVc_V), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','middle', 'Interpreter','tex'); - end - if isfinite(aSteadyMidX) && isfinite(A.Va_baseline_V) && isfinite(A.Va_ss_V) - plot(ax, [aSteadyMidX aSteadyMidX], [A.Va_baseline_V A.Va_ss_V], '--', ... - 'Color',[0.80 0.35 0.10], 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, aSteadyMidX, 0.5*(A.Va_baseline_V + A.Va_ss_V), sprintf(' Anod dV = %.4f V', A.dVa_V), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','middle', 'Interpreter','tex'); - end - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08 * dy; - yLow = yl(2) - 0.16 * dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); - drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); -end - -function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... - cathStartX, cathEndX, anodStartX, anodEndX) - drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Ic_est_A, [0.10 0.35 0.80], '--'); - drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Ia_est_A, [0.80 0.35 0.10], '--'); - - plot(ax, cSteadyEndX, A.Ic_est_A, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - plot(ax, aSteadyEndX, A.Ia_est_A, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - - text(ax, cSteadyEndX, A.Ic_est_A, sprintf(' Cath current = %.3f mA', 1e3 * A.Ic_est_A), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); - text(ax, aSteadyEndX, A.Ia_est_A, sprintf(' Anod current = %.3f mA', 1e3 * A.Ia_est_A), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08 * dy; - yLow = yl(2) - 0.16 * dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); - drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); -end - -function drawDurationBracket(ax, x1, x2, y, labelText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - yl = ylim(ax); - h = 0.025 * (yl(2) - yl(1)); - plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, 0.5 * (x1 + x2), y + 1.4 * h, labelText, 'HorizontalAlignment','center', ... - 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1, 'HandleVisibility','off'); -end - -function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) - if ~isfinite(y) - return; - end - if isfinite(x1) && isfinite(x2) && x2 > x1 - xStart = x1; - xEnd = x2; - else - xl = xlim(ax); - xStart = xl(1) + 0.04 * (xl(2) - xl(1)); - xEnd = xStart + 0.18 * (xl(2) - xl(1)); - end - plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); - text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... - 'BackgroundColor','w', 'Margin',1, 'Interpreter','none', 'HandleVisibility','off'); -end - -function drawLevelSegment(ax, x1, x2, y, color, lineStyle) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - plot(ax, [x1 x2], [y y], lineStyle, 'Color', color, 'LineWidth',1.3, 'HandleVisibility','off'); -end - -function xm = midpointFinite(x1, x2) - if isfinite(x1) && isfinite(x2) - xm = 0.5 * (x1 + x2); - else - xm = NaN; - end -end - -function txt = formatDurationUs(dt_s) - if ~isscalar(dt_s) || ~isfinite(dt_s) || dt_s < 0 - txt = '-'; - else - txt = sprintf('%.3f us', 1e6 * dt_s); - end -end - -function s = csvEscape(x) - s = strrep(char(x), '"', '""'); -end - -function v = interp1Safe(x, y, xq) - if numel(x) < 2 || any(~isfinite([x(:); y(:)])) - v = NaN; - return; - end - - try - v = interp1(x, y, xq, 'linear', 'extrap'); - catch - idx = nearestIndex(x, xq); - v = y(idx); - end -end - -function idx = nearestIndex(x, xq) - [~, idx] = min(abs(x - xq)); -end - -function m = medianInWindow(t, y, t1, t2) - if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 - m = NaN; - return; - end - - mask = t >= t1 & t <= t2; - if ~any(mask) - m = NaN; - else - m = median(y(mask), 'omitnan'); - end -end diff --git a/apps/electrochem/private/vtResistanceWorkflow.m b/apps/electrochem/private/vtResistanceWorkflow.m index 73db5cd..f762294 100644 --- a/apps/electrochem/private/vtResistanceWorkflow.m +++ b/apps/electrochem/private/vtResistanceWorkflow.m @@ -1,12 +1,14 @@ % App-owned VT resistance workflow helper dispatch. Expected caller: % labkit_VTResistance_app callbacks and workflow tests. % Inputs are a command string plus the original helper arguments; outputs match -% the selected helper. Side effects are limited to CSV writes. +% the selected helper. Side effects are limited to CSV export writes and drawing +% app-owned plot annotations on caller axes. function varargout = vtResistanceWorkflow(command, varargin) %VTRESISTANCEWORKFLOW Dispatch app-owned VT resistance helpers. % Expected caller: labkit_VTResistance_app callbacks and temporary compatibility % workflow tests. Inputs are a command string plus the original helper arguments. -% Outputs match the selected helper. Side effects are limited to CSV writes. +% Outputs match the selected helper. Side effects are limited to CSV export +% writes and drawing app-owned plot annotations on caller axes. switch string(command) case "computeResistance" @@ -17,6 +19,16 @@ varargout{1} = buildResultsTable(varargin{:}); case "writeResultsCSV" [varargout{1:nargout}] = writeResultsCSV(varargin{:}); + case "formatDurationUs" + varargout{1} = formatDurationUs(varargin{:}); + case "interp1Safe" + varargout{1} = interp1Safe(varargin{:}); + case "shadeWindow" + shadeWindow(varargin{:}); + case "addResistanceVTAnnotations" + addResistanceVTAnnotations(varargin{:}); + case "addResistanceITAnnotations" + addResistanceITAnnotations(varargin{:}); otherwise error('labkit:VTResistance:UnknownWorkflowCommand', ... 'Unknown VT resistance workflow helper command: %s.', command); diff --git a/buildfile.m b/buildfile.m index 51a8099..5a91c69 100644 --- a/buildfile.m +++ b/buildfile.m @@ -31,136 +31,116 @@ function checkStyleTask(~) runBuildTests("checkStyle", ... "Suites", "project", ... - "Tags", "Style", ... - "FailIfNoTests", false); + "Tags", "Style"); end function testTask(~) runBuildTests("test", ... - "IncludeGui", false, ... - "FailIfNoTests", false); + "IncludeGui", false); end function testUnitTask(~) runBuildTests("testUnit", ... - "Tags", "Unit", ... - "FailIfNoTests", false); + "Tags", "Unit"); end function testIntegrationTask(~) runBuildTests("testIntegration", ... - "Tags", "Integration", ... - "FailIfNoTests", false); + "Tags", "Integration"); end function testProjectTask(~) runBuildTests("testProject", ... - "Suites", "project", ... - "FailIfNoTests", false); + "Suites", "project"); end function testLabkitDtaTask(~) runBuildTests("testLabkitDta", ... - "Suites", "labkit/dta", ... - "FailIfNoTests", false); + "Suites", "labkit/dta"); end function testLabkitBiosignalTask(~) runBuildTests("testLabkitBiosignal", ... - "Suites", "labkit/biosignal", ... - "FailIfNoTests", false); + "Suites", "labkit/biosignal"); end function testLabkitUiTask(~) runBuildTests("testLabkitUi", ... "Suites", "labkit/ui", ... - "IncludeGui", false, ... - "FailIfNoTests", false); + "IncludeGui", false); end function testLabkitUiGuiTask(~) runBuildTests("testLabkitUiGui", ... "Suites", "labkit/ui", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsElectrochemTask(~) runBuildTests("testAppsElectrochem", ... "Suites", "apps/electrochem", ... - "IncludeGui", false, ... - "FailIfNoTests", false); + "IncludeGui", false); end function testAppsElectrochemGuiTask(~) runBuildTests("testAppsElectrochemGui", ... "Suites", "apps/electrochem", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsDicGuiTask(~) runBuildTests("testAppsDicGui", ... "Suites", "apps/dic", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsImageMeasurementTask(~) runBuildTests("testAppsImageMeasurement", ... "Suites", "apps/image_measurement", ... - "IncludeGui", false, ... - "FailIfNoTests", false); + "IncludeGui", false); end function testAppsImageMeasurementGuiTask(~) runBuildTests("testAppsImageMeasurementGui", ... "Suites", "apps/image_measurement", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsWearableGuiTask(~) runBuildTests("testAppsWearableGui", ... "Suites", "apps/wearable", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsGuiTask(~) runBuildTests("testAppsGui", ... "Suites", "apps", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testAppsSmokeGuiTask(~) runBuildTests("testAppsSmokeGui", ... "Suites", "apps/smoke", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testGuiStructuralTask(~) runBuildTests("testGuiStructural", ... "Suites", "gui", ... "Tags", "Structural", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function testGuiGestureTask(~) runBuildTests("testGuiGesture", ... "Tags", "Gesture", ... - "IncludeGui", true, ... - "FailIfNoTests", false); + "IncludeGui", true); end function coverageTask(~) runBuildTests("coverage", ... "Tags", ["Unit", "Integration"], ... - "IncludeCoverage", true, ... - "FailIfNoTests", false); + "IncludeCoverage", true); end function checkProjectTask(~) diff --git a/docs/testing.md b/docs/testing.md index 08c9b44..e171268 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,6 +43,10 @@ buildtool packageDryRun - `buildtool checkStyle` runs official project/style guardrails. - `buildtool coverage` generates official JUnit, HTML test result, Cobertura, and HTML coverage artifacts. Coverage is report-only. +- Official runner artifacts are namespaced by build task run name under + `artifacts/test-results//`, `artifacts/coverage//`, + `artifacts/gui//`, and `artifacts/logs//` so combined task + invocations do not overwrite each other. - `buildtool testGuiGesture` runs focused noninteractive gesture coverage for runtime, anchor editor, and scale-bar interaction lifecycle checks. - `buildtool checkProject` verifies `LabKit.prj` path and startup metadata. @@ -72,6 +76,15 @@ such as `--suite`, `--test`, and `--gui` are not supported. Set `MATLAB_CMD` when MATLAB is not on `PATH`, set `MATLAB_FLAGS` for MATLAB startup flags, and set `MATLAB_TEST_LOG` to override the default `matlab_test.log` location. +Advanced targeted debugging can call the internal runner directly: + +```matlab +runLabKitTests("Tests", "AppHookHelpersTest", "FailIfNoTests", true) +``` + +Use direct `runLabKitTests(...)` calls only for local diagnosis. Build tasks +remain the official entry points for CI, PR validation, and handoff commands. + ## Validation Levels | Level | Where | Purpose | diff --git a/scripts/run_matlab_tests.sh b/scripts/run_matlab_tests.sh index a83c618..0219033 100755 --- a/scripts/run_matlab_tests.sh +++ b/scripts/run_matlab_tests.sh @@ -127,7 +127,11 @@ fi rm -f "$LOG_FILE" set +e -"$MATLAB_BIN" "${MATLAB_FLAG_ARGS[@]}" -logfile "$LOG_FILE" -batch "cd($(matlab_literal "$ROOT_DIR")); buildtool $TASK_TEXT;" +if [[ ${#MATLAB_FLAG_ARGS[@]} -gt 0 ]]; then + "$MATLAB_BIN" "${MATLAB_FLAG_ARGS[@]}" -logfile "$LOG_FILE" -batch "cd($(matlab_literal "$ROOT_DIR")); buildtool $TASK_TEXT;" +else + "$MATLAB_BIN" -logfile "$LOG_FILE" -batch "cd($(matlab_literal "$ROOT_DIR")); buildtool $TASK_TEXT;" +fi status=$? set -e diff --git a/tests/runLabKitTests.m b/tests/runLabKitTests.m index e22de7a..2b4c286 100644 --- a/tests/runLabKitTests.m +++ b/tests/runLabKitTests.m @@ -20,7 +20,11 @@ setupLabKitTestPath(); opts = parseOptions(root, varargin{:}); - paths = labkitArtifactPaths("Root", opts.ArtifactsRoot, "Create", true); + restoreRunName = setRunNameEnvironment(opts.RunName); + paths = labkitArtifactPaths( ... + "Root", opts.ArtifactsRoot, ... + "RunName", opts.RunName, ... + "Create", true); suite = discoverOfficialSuite(root, opts); fprintf("LabKit official test run: %s\n", opts.RunName); @@ -66,6 +70,13 @@ "official", officialResults, ... "artifacts", paths, ... "runName", opts.RunName); + clear restoreRunName +end + +function cleanup = setRunNameEnvironment(runName) + previousRunName = getenv("LABKIT_RUN_NAME"); + setenv("LABKIT_RUN_NAME", char(runName)); + cleanup = onCleanup(@() setenv("LABKIT_RUN_NAME", previousRunName)); end function opts = parseOptions(root, varargin) diff --git a/tests/support/labkitArtifactPaths.m b/tests/support/labkitArtifactPaths.m index d332ff1..e648898 100644 --- a/tests/support/labkitArtifactPaths.m +++ b/tests/support/labkitArtifactPaths.m @@ -3,6 +3,7 @@ % % Expected caller: runners, tests, and GUI artifact helpers. Options: % Root artifact root directory, default /artifacts +% RunName optional run name used to namespace official build artifacts % Create logical flag that creates directories when true % % Output fields include JUnit XML, HTML test results, coverage, MATLAB log, @@ -10,23 +11,26 @@ p = inputParser; p.addParameter("Root", defaultArtifactRoot(), @(v) ischar(v) || isstring(v)); + p.addParameter("RunName", getenv("LABKIT_RUN_NAME"), @isTextScalar); p.addParameter("Create", false, @(v) islogical(v) || isnumeric(v)); p.parse(varargin{:}); artifactRoot = char(p.Results.Root); + runName = sanitizeRunName(p.Results.RunName); createDirs = logical(p.Results.Create); paths = struct(); paths.root = artifactRoot; - paths.testResults = fullfile(artifactRoot, "test-results"); + paths.runName = runName; + paths.testResults = artifactPath(artifactRoot, "test-results", runName); paths.junitXml = fullfile(paths.testResults, "junit.xml"); paths.testHtml = fullfile(paths.testResults, "html"); - paths.coverage = fullfile(artifactRoot, "coverage"); + paths.coverage = artifactPath(artifactRoot, "coverage", runName); paths.coberturaXml = fullfile(paths.coverage, "cobertura.xml"); paths.coverageHtml = fullfile(paths.coverage, "html"); - paths.logs = fullfile(artifactRoot, "logs"); + paths.logs = artifactPath(artifactRoot, "logs", runName); paths.matlabLog = fullfile(paths.logs, "matlab.log"); - paths.gui = fullfile(artifactRoot, "gui"); + paths.gui = artifactPath(artifactRoot, "gui", runName); paths.guiTrace = fullfile(paths.gui, "trace"); paths.guiSnapshots = fullfile(paths.gui, "snapshots"); @@ -42,6 +46,26 @@ end end +function path = artifactPath(root, category, runName) + if strlength(runName) > 0 + path = fullfile(root, category, char(runName)); + else + path = fullfile(root, category); + end +end + +function runName = sanitizeRunName(value) + runName = string(value); + if strlength(runName) == 0 + return; + end + runName = regexprep(runName, "[^A-Za-z0-9_.-]+", "_"); + runName = regexprep(runName, "^_+|_+$", ""); + if strlength(runName) == 0 + runName = "run"; + end +end + function root = defaultArtifactRoot() envRoot = getenv("LABKIT_ARTIFACTS"); if strlength(string(envRoot)) > 0 @@ -56,3 +80,7 @@ function ensureDirectory(folder) mkdir(folder); end end + +function tf = isTextScalar(value) + tf = ischar(value) || (isstring(value) && isscalar(value)); +end diff --git a/tests/unit/project/PlatformSkeletonTest.m b/tests/unit/project/PlatformSkeletonTest.m index acf629b..ddc30c6 100644 --- a/tests/unit/project/PlatformSkeletonTest.m +++ b/tests/unit/project/PlatformSkeletonTest.m @@ -8,6 +8,7 @@ function artifactPathsUseRoadmapLayout(testCase) setupLabKitTestPath(); paths = labkitArtifactPaths( ... "Root", fullfile(tempdir, "labkit-artifacts-seed"), ... + "RunName", "", ... "Create", false); testCase.verifyTrue(endsWith(string(paths.junitXml), ... @@ -22,12 +23,30 @@ function artifactPathsUseRoadmapLayout(testCase) fullfile("gui", "trace"))); testCase.verifyTrue(endsWith(string(paths.guiSnapshots), ... fullfile("gui", "snapshots"))); + + runPaths = labkitArtifactPaths( ... + "Root", fullfile(tempdir, "labkit-artifacts-seed"), ... + "RunName", "testUnit", ... + "Create", false); + testCase.verifyTrue(endsWith(string(runPaths.junitXml), ... + fullfile("test-results", "testUnit", "junit.xml"))); + testCase.verifyTrue(endsWith(string(runPaths.testHtml), ... + fullfile("test-results", "testUnit", "html"))); + testCase.verifyTrue(endsWith(string(runPaths.coberturaXml), ... + fullfile("coverage", "testUnit", "cobertura.xml"))); + testCase.verifyTrue(endsWith(string(runPaths.coverageHtml), ... + fullfile("coverage", "testUnit", "html"))); + testCase.verifyTrue(endsWith(string(runPaths.guiTrace), ... + fullfile("gui", "testUnit", "trace"))); + testCase.verifyTrue(endsWith(string(runPaths.guiSnapshots), ... + fullfile("gui", "testUnit", "snapshots"))); end function traceArtifactsAreStructuredAndSanitized(testCase) setupLabKitTestPath(); paths = labkitArtifactPaths( ... "Root", fullfile(tempdir, "labkit-trace-seed"), ... + "RunName", "PlatformSkeletonTest", ... "Create", true); jsonlPath = fullfile(paths.guiTrace, "trace.jsonl"); textPath = fullfile(paths.guiTrace, "trace.txt");