diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 013b8ca..cd35fac 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -30,8 +30,7 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl package components instead of adding new `private/` runners or string-dispatch workflow adapters. - Do not add new `*Workflow.m` files or app-owned `+core/dispatch.m` string - routers. The existing electrochemistry dispatch files are temporary migration - debt documented in `docs/architecture.md`. + routers. - When a public app file grows large, prefer moving GUI-free app-owned calculations, export builders, formatting utilities, deterministic image/signal transforms, and focused control construction into `apps///+/...`. - Do not add new `apps//private/` helpers unless the helper is genuinely shared by multiple apps in that family and the user approves that family-level boundary. - Keep the public app entry point responsible for GUI state, callbacks, user alerts, app workflow order, debug launch routing, and user-facing log wording. diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+core/dispatch.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+core/dispatch.m deleted file mode 100644 index 688e2d5..0000000 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+core/dispatch.m +++ /dev/null @@ -1,283 +0,0 @@ -% App-owned chrono overlay helper core dispatch. Expected caller: -% labkit_ChronoOverlay_app callbacks and package tests. -% Inputs are a command string plus the original helper arguments; outputs match -% the selected helper. Side effects are limited to drawing app-owned overlay -% plots on caller axes. -function varargout = dispatch(command, varargin) -%DISPATCH Route chrono overlay package wrapper calls to app-owned helpers. -% Expected caller: labkit_ChronoOverlay_app callbacks and % package tests. Inputs are a command string plus the original helper arguments. -% Outputs match the selected helper. Side effects are limited to drawing -% app-owned overlay plots on caller axes. - - switch string(command) - case "alignByPulseGap" - [varargout{1:nargout}] = alignByPulseGap(varargin{:}); - case "buildOverlayExportTable" - varargout{1} = buildOverlayExportTable(varargin{:}); - case "plotVTIT" - plotVTIT(varargin{:}); - otherwise - error('labkit:ChronoOverlay:UnknownWorkflowCommand', ... - 'Unknown chrono overlay workflow helper command: %s.', command); - end -end -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/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m index e3b5f25..7e54bd8 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+export/buildOverlayExportTable.m @@ -1,6 +1,67 @@ % Expected caller: chrono overlay app runner and export tests. Inputs are aligned % chrono item structs. Output is the stable overlay export table. No file side % effects. + function T = buildOverlayExportTable(items) - T = chrono_overlay.core.dispatch("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 + +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 out = sanitizeFieldName(txt) + out = matlab.lang.makeValidName(txt); end diff --git a/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m index ed113e1..7e63827 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+ops/alignByPulseGap.m @@ -1,6 +1,88 @@ % Expected caller: chrono overlay app runner and unit tests. Inputs are one % chrono item struct with time/current/voltage and pulse fields. Outputs return % the aligned item and status message. No file or UI side effects. + function [item, msg] = alignByPulseGap(item) - [item, msg] = chrono_overlay.core.dispatch("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 + +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 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/chrono_overlay/+chrono_overlay/+view/plotVTIT.m b/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m index bceb18c..6d3be16 100644 --- a/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m +++ b/apps/electrochem/chrono_overlay/+chrono_overlay/+view/plotVTIT.m @@ -1,6 +1,142 @@ % Expected caller: chrono overlay app runner. Inputs are voltage/current axes, % aligned item structs, and plot option fields. Side effects are limited to % redrawing the supplied axes. + function plotVTIT(axV, axI, items, opts) - chrono_overlay.core.dispatch("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 + +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 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 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 diff --git a/apps/electrochem/cic/+cic/+core/dispatch.m b/apps/electrochem/cic/+cic/+core/dispatch.m deleted file mode 100644 index ec1b6df..0000000 --- a/apps/electrochem/cic/+cic/+core/dispatch.m +++ /dev/null @@ -1,719 +0,0 @@ -% App-owned CIC helper core dispatch. Expected caller: labkit_CIC_app -% callbacks and package 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 = dispatch(command, varargin) -%DISPATCH Route CIC package wrapper calls to app-owned helpers. -% Expected caller: labkit_CIC_app callbacks and package 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. - - switch string(command) - case "computeCIC" - varargout{1} = computeCIC(varargin{:}); - case "buildBatchTableData" - [varargout{1:nargout}] = buildBatchTableData(varargin{:}); - case "buildResultsTable" - 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); - end -end -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/cic/+cic/+export/buildResultsTable.m b/apps/electrochem/cic/+cic/+export/buildResultsTable.m index 3030a0a..792c5df 100644 --- a/apps/electrochem/cic/+cic/+export/buildResultsTable.m +++ b/apps/electrochem/cic/+cic/+export/buildResultsTable.m @@ -1,6 +1,82 @@ % Expected caller: CIC app runner and export tests. Inputs are item structs and % display unit label. Output is the stable CIC CSV result table. No file side % effects. + function T = buildResultsTable(items, unitLabel) - T = cic.core.dispatch("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 [scale, unitSuffix] = displayScaleSuffix(unitLabel) + [scale, unitLabel] = displayScale(unitLabel); + unitSuffix = regexprep(unitLabel, '[\^/]', ''); +end + +function [scale, unitLabel] = displayScale(unitLabel) + switch unitLabel + case 'uC/cm^2' + scale = 1e3; + otherwise + scale = 1; + unitLabel = 'mC/cm^2'; + end +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 diff --git a/apps/electrochem/cic/+cic/+export/writeResultsCSV.m b/apps/electrochem/cic/+cic/+export/writeResultsCSV.m index af22c7f..46d88e7 100644 --- a/apps/electrochem/cic/+cic/+export/writeResultsCSV.m +++ b/apps/electrochem/cic/+cic/+export/writeResultsCSV.m @@ -1,6 +1,126 @@ % Expected caller: CIC app runner and export tests. Inputs are item structs, % output filepath, and display unit label. Side effect is writing the stable CIC % CSV file. + function [ok, msg] = writeResultsCSV(items, filepath, unitLabel) - [ok, msg] = cic.core.dispatch("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 + +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 [scale, unitSuffix] = displayScaleSuffix(unitLabel) + [scale, unitLabel] = displayScale(unitLabel); + unitSuffix = regexprep(unitLabel, '[\^/]', ''); +end + +function [scale, unitLabel] = displayScale(unitLabel) + switch unitLabel + case 'uC/cm^2' + scale = 1e3; + otherwise + scale = 1; + unitLabel = 'mC/cm^2'; + end +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 diff --git a/apps/electrochem/cic/+cic/+ops/computeCIC.m b/apps/electrochem/cic/+cic/+ops/computeCIC.m index 0035142..1784002 100644 --- a/apps/electrochem/cic/+cic/+ops/computeCIC.m +++ b/apps/electrochem/cic/+cic/+ops/computeCIC.m @@ -1,6 +1,310 @@ % Expected caller: CIC app runner and unit tests. Inputs are a DTA item struct % and CIC option struct. Output is the stable CIC analysis result struct. No file % or UI side effects. + function A = computeCIC(item, opts) - A = cic.core.dispatch("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 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 [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 [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 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 + +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 diff --git a/apps/electrochem/cic/+cic/+ops/interp1Safe.m b/apps/electrochem/cic/+cic/+ops/interp1Safe.m index 54777d7..1c3009c 100644 --- a/apps/electrochem/cic/+cic/+ops/interp1Safe.m +++ b/apps/electrochem/cic/+cic/+ops/interp1Safe.m @@ -1,5 +1,20 @@ % Expected caller: CIC app plotting helpers. Inputs are vectors x/y and query % points. Output mirrors the app-owned safe interpolation helper. No side effects. + function v = interp1Safe(x, y, xq) - v = cic.core.dispatch("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 diff --git a/apps/electrochem/cic/+cic/+view/addBaselineYLines.m b/apps/electrochem/cic/+cic/+view/addBaselineYLines.m index e2ff6a8..821c309 100644 --- a/apps/electrochem/cic/+cic/+view/addBaselineYLines.m +++ b/apps/electrochem/cic/+cic/+view/addBaselineYLines.m @@ -1,5 +1,32 @@ % Expected caller: CIC app plotting helpers. Inputs are an axes and CIC result % struct. Side effects are limited to drawing baseline guides on the axes. + function addBaselineYLines(ax, A) - cic.core.dispatch("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 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 diff --git a/apps/electrochem/cic/+cic/+view/addPaperStyleITAnnotations.m b/apps/electrochem/cic/+cic/+view/addPaperStyleITAnnotations.m index aead370..813094c 100644 --- a/apps/electrochem/cic/+cic/+view/addPaperStyleITAnnotations.m +++ b/apps/electrochem/cic/+cic/+view/addPaperStyleITAnnotations.m @@ -1,6 +1,76 @@ % Expected caller: CIC app plotting helpers. Inputs mirror the app-owned IT % annotation helper. Side effects are limited to annotating the supplied axes. -function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX) - cic.core.dispatch("addPaperStyleITAnnotations", ax, A, xChoice, ... - cathStartX, cathEndX, anodStartX, anodEndX); + +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 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 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 x = chooseX(A, xChoice) + if strcmp(xChoice, 'Sample #') + x = A.pt; + else + x = A.t; + 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 diff --git a/apps/electrochem/cic/+cic/+view/addPaperStyleVTAnnotations.m b/apps/electrochem/cic/+cic/+view/addPaperStyleVTAnnotations.m index 3570bfc..9e773af 100644 --- a/apps/electrochem/cic/+cic/+view/addPaperStyleVTAnnotations.m +++ b/apps/electrochem/cic/+cic/+view/addPaperStyleVTAnnotations.m @@ -1,6 +1,126 @@ % Expected caller: CIC app plotting helpers. Inputs mirror the app-owned VT % annotation helper. Side effects are limited to annotating the supplied axes. + function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) - cic.core.dispatch("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 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 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 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 diff --git a/apps/electrochem/cic/+cic/+view/buildBatchTableData.m b/apps/electrochem/cic/+cic/+view/buildBatchTableData.m index 9d56188..7a52dad 100644 --- a/apps/electrochem/cic/+cic/+view/buildBatchTableData.m +++ b/apps/electrochem/cic/+cic/+view/buildBatchTableData.m @@ -1,6 +1,73 @@ % Expected caller: CIC app runner and unit tests. Inputs are item structs and a % display unit label. Outputs are the stable UI table cell data and column names. % No file side effects. + function [C, columnNames] = buildBatchTableData(items, unitLabel) - [C, columnNames] = cic.core.dispatch("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 [scale, unitLabel] = displayScale(unitLabel) + switch unitLabel + case 'uC/cm^2' + scale = 1e3; + otherwise + scale = 1; + unitLabel = 'mC/cm^2'; + end +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 txt = ternary(cond, a, b) + if cond + txt = a; + else + txt = b; + end end diff --git a/apps/electrochem/cic/+cic/+view/formatChargeDensity.m b/apps/electrochem/cic/+cic/+view/formatChargeDensity.m index 75f4721..6cf008f 100644 --- a/apps/electrochem/cic/+cic/+view/formatChargeDensity.m +++ b/apps/electrochem/cic/+cic/+view/formatChargeDensity.m @@ -1,5 +1,17 @@ % Expected caller: CIC app runner. Inputs are charge, density, and display unit % label. Output is the stable read-only UI text. No side effects. -function txt = formatChargeDensity(charge_C, density_mCcm2, unitLabel) - txt = cic.core.dispatch("formatChargeDensity", charge_C, density_mCcm2, unitLabel); + +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 diff --git a/apps/electrochem/cic/+cic/+view/formatMaybeNum.m b/apps/electrochem/cic/+cic/+view/formatMaybeNum.m index 846b945..53b1c12 100644 --- a/apps/electrochem/cic/+cic/+view/formatMaybeNum.m +++ b/apps/electrochem/cic/+cic/+view/formatMaybeNum.m @@ -1,5 +1,10 @@ % Expected caller: CIC app runner. Inputs are a numeric value and sprintf format. % Output is the stable UI text for finite or missing values. No side effects. -function txt = formatMaybeNum(value, fmt) - txt = cic.core.dispatch("formatMaybeNum", value, fmt); + +function s = formatMaybeNum(v, fmt) + if isfinite(v) + s = sprintf(fmt, v); + else + s = 'NaN'; + end end diff --git a/apps/electrochem/cic/+cic/+view/shadeWindow.m b/apps/electrochem/cic/+cic/+view/shadeWindow.m index 00a389d..3bfe7e0 100644 --- a/apps/electrochem/cic/+cic/+view/shadeWindow.m +++ b/apps/electrochem/cic/+cic/+view/shadeWindow.m @@ -1,5 +1,12 @@ % Expected caller: CIC app plotting helpers. Inputs are an axes, x-window, and % color. Side effects are limited to adding the app-owned patch to the axes. + function shadeWindow(ax, x1, x2, color) - cic.core.dispatch("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 diff --git a/apps/electrochem/csc/+csc/+core/dispatch.m b/apps/electrochem/csc/+csc/+core/dispatch.m deleted file mode 100644 index 4ccd49f..0000000 --- a/apps/electrochem/csc/+csc/+core/dispatch.m +++ /dev/null @@ -1,329 +0,0 @@ -% App-owned CSC helper core dispatch. Expected caller: labkit_CSC_app -% callbacks and package tests. Inputs are a command -% string plus the original helper arguments; outputs match the selected helper. -% This helper has no file side effects. -function varargout = dispatch(command, varargin) -%DISPATCH Route CSC package wrapper calls to app-owned helpers. -% Expected caller: labkit_CSC_app callbacks and package tests. Inputs are a -% command string plus the original helper arguments. -% Outputs match the selected helper. This helper has no file side effects. - - switch string(command) - case "computeCSC" - varargout{1} = computeCSC(varargin{:}); - otherwise - error('labkit:CSC:UnknownWorkflowCommand', ... - 'Unknown CSC workflow helper command: %s.', command); - end -end -function A = computeCSC(curve, opts) -%COMPUTECSC Compute CV/CT charge comparison and CSC for the CSC app. - - if nargin < 2 - opts = struct(); - end - opts = fillOptions(opts); - - A = struct(); - A.ok = false; - A.message = ''; - A.logMessage = ''; - A.mode = opts.mode; - A.scanRate = opts.scanRate; - A.area_cm2 = parsePositiveScalar(opts.area_cm2); - - if ~(isscalar(A.scanRate) && isfinite(A.scanRate) && A.scanRate > 0) - A.message = 'scan rate missing'; - A.logMessage = 'Compare skipped: scan rate missing.'; - return; - end - - if ~hasExactColumns(curve, {'T', 'Vf', 'Im'}) - A.message = 'Need T, Vf, Im'; - A.logMessage = 'Compare skipped: T/Vf/Im not all present.'; - return; - end - - t = exactColumn(curve, 'T'); - V = exactColumn(curve, 'Vf'); - I = exactColumn(curve, 'Im'); - - good = ~(isnan(t) | isnan(V) | isnan(I)); - t = t(good); - V = V(good); - I = I(good); - - if numel(t) < 2 - A.message = 'Not enough points'; - A.logMessage = 'Compare skipped: not enough valid points.'; - return; - end - - CT = computeCTCharge(t, V, I); - CV = computeCVCharge(t, V, I, A.scanRate); - if ~CT.ok - A.message = CT.message; - A.logMessage = 'Compare skipped: not enough valid points.'; - return; - end - if ~CV.ok - A.message = CV.message; - A.logMessage = 'Compare skipped: scan rate missing.'; - return; - end - - A.t = t; - A.Vf = V; - A.Im = I; - A.IcathDisp = CT.IcathDisp; - A.IanodDisp = CT.IanodDisp; - A.QctCath = CT.QctCath; - A.QctAnod = CT.QctAnod; - A.QctFull = CT.QctFull; - A.QcvCath = CV.QcvCath; - A.QcvAnod = CV.QcvAnod; - A.QcvFull = CV.QcvFull; - A.dtErr = CV.dtErr; - - switch A.mode - case 'Cathodic' - A.Qct = A.QctCath; - A.Qcv = A.QcvCath; - case 'Anodic' - A.Qct = A.QctAnod; - A.Qcv = A.QcvAnod; - otherwise - A.mode = 'Full'; - A.Qct = A.QctFull; - A.Qcv = A.QcvFull; - end - - A.diff_C = A.Qct - A.Qcv; - denom = max(abs(A.Qct), abs(A.Qcv)); - if denom == 0 - A.rel_pct = 0; - else - A.rel_pct = 100 * abs(A.diff_C) / denom; - end - - if isfinite(A.area_cm2) && A.area_cm2 > 0 - A.Qct_mC_cm2 = 1e3 * A.Qct / A.area_cm2; - A.Qcv_mC_cm2 = 1e3 * A.Qcv / A.area_cm2; - A.diff_mC_cm2 = 1e3 * A.diff_C / A.area_cm2; - else - A.Qct_mC_cm2 = NaN; - A.Qcv_mC_cm2 = NaN; - A.diff_mC_cm2 = NaN; - end - - A.ok = true; - A.message = 'OK'; -end - -%% Small app-local utilities -function opts = fillOptions(opts) - if ~isfield(opts, 'mode') - opts.mode = 'Full'; - end - if ~isfield(opts, 'scanRate') - opts.scanRate = NaN; - end - if ~isfield(opts, 'area_cm2') - opts.area_cm2 = NaN; - end -end - -function tf = hasExactColumns(curve, names) - tf = isfield(curve, 'headers'); - if ~tf - return; - end - for k = 1:numel(names) - if ~any(strcmp(curve.headers, names{k})) - tf = false; - return; - end - end -end - -function col = exactColumn(curve, name) - idx = find(strcmp(curve.headers, name), 1); - if isempty(idx) - col = []; - else - col = curve.data(:, idx); - end -end - -function R = computeCTCharge(t, V, I) - R = struct(); - R.ok = false; - R.message = ''; - - if nargin < 3 || numel(t) < 2 || numel(V) < 2 || numel(I) < 2 - R.message = 'Not enough points'; - R = fillEmptyCT(R); - return; - end - - S = integrateCVCTSignSplit(t, V, I, NaN); - R = copyFields(R, S, {'QctCath', 'QctAnod', 'IcathDisp', 'IanodDisp'}); - R.QctFull = R.QctCath + R.QctAnod; - R.ok = true; - R.message = 'OK'; -end - -function R = computeCVCharge(t, V, I, scanRate) - R = struct(); - R.ok = false; - R.message = ''; - - if nargin < 4 || ~(isscalar(scanRate) && isfinite(scanRate) && scanRate > 0) - R.message = 'scan rate missing'; - R = fillEmptyCV(R); - return; - end - if numel(t) < 2 || numel(V) < 2 || numel(I) < 2 - R.message = 'Not enough points'; - R = fillEmptyCV(R); - return; - end - - S = integrateCVCTSignSplit(t, V, I, scanRate); - R = copyFields(R, S, {'QcvCath', 'QcvAnod', 'dtErr', 'IcathDisp', 'IanodDisp'}); - R.QcvFull = R.QcvCath + R.QcvAnod; - R.ok = true; - R.message = 'OK'; -end - -function R = integrateCVCTSignSplit(t, V, I, scanRate) - if nargin < 4 - scanRate = NaN; - end - - t = t(:); - V = V(:); - I = I(:); - - R = struct(); - R.QctCath = 0; - R.QctAnod = 0; - R.QcvCath = 0; - R.QcvAnod = 0; - R.dtErr = NaN; - - R.IcathDisp = I; - R.IanodDisp = I; - R.IcathDisp(I >= 0) = NaN; - R.IanodDisp(I <= 0) = NaN; - - dtErrList = []; - useCV = isscalar(scanRate) && isfinite(scanRate) && scanRate > 0; - - for k = 1:numel(t)-1 - t1 = t(k); t2 = t(k+1); - V1 = V(k); V2 = V(k+1); - I1 = I(k); I2 = I(k+1); - - if any(~isfinite([t1 t2 V1 V2 I1 I2])) - continue; - end - - bp = [0, 1]; - s0 = crossingFraction(I1, I2, 0); - if ~isempty(s0) - bp(end+1) = s0; %#ok - end - bp = unique(sort(bp)); - - for j = 1:numel(bp)-1 - sa = bp(j); - sb = bp(j+1); - - ta = lerp(t1, t2, sa); - tb = lerp(t1, t2, sb); - Va = lerp(V1, V2, sa); - Vb = lerp(V1, V2, sb); - Ia = lerp(I1, I2, sa); - Ib = lerp(I1, I2, sb); - - Imid = 0.5 * (Ia + Ib); - if Imid < 0 - R.QctCath = R.QctCath + abs(trapz([ta tb], [Ia Ib])); - elseif Imid > 0 - R.QctAnod = R.QctAnod + trapz([ta tb], [Ia Ib]); - end - - if useCV - dt_act = tb - ta; - dt_cv = abs(Vb - Va) / scanRate; - dtErrList(end+1) = abs(dt_act - dt_cv); %#ok - - if Imid < 0 - R.QcvCath = R.QcvCath + abs(trapz([0 dt_cv], [Ia Ib])); - elseif Imid > 0 - R.QcvAnod = R.QcvAnod + trapz([0 dt_cv], [Ia Ib]); - end - end - end - end - - if ~isempty(dtErrList) - R.dtErr = max(dtErrList); - end -end - -function R = fillEmptyCT(R) - R.QctCath = 0; - R.QctAnod = 0; - R.QctFull = 0; - R.IcathDisp = []; - R.IanodDisp = []; -end - -function R = fillEmptyCV(R) - R.QcvCath = 0; - R.QcvAnod = 0; - R.QcvFull = 0; - R.dtErr = NaN; - R.IcathDisp = []; - R.IanodDisp = []; -end - -function out = copyFields(out, in, names) - for k = 1:numel(names) - out.(names{k}) = in.(names{k}); - end -end - -function y = lerp(a, b, s) - y = a + s * (b - a); -end - -function s = crossingFraction(y1, y2, y0) - if ~isfinite(y1) || ~isfinite(y2) || y1 == y2 - s = []; - return; - end - s = (y0 - y1) / (y2 - y1); - if ~(s > 0 && s < 1) - s = []; - 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 diff --git a/apps/electrochem/csc/+csc/+ops/computeCSC.m b/apps/electrochem/csc/+csc/+ops/computeCSC.m index 45fa6b3..b869aeb 100644 --- a/apps/electrochem/csc/+csc/+ops/computeCSC.m +++ b/apps/electrochem/csc/+csc/+ops/computeCSC.m @@ -1,6 +1,314 @@ % Expected caller: CSC app runner and unit tests. Inputs are a CV/CT curve struct % and CSC options. Output is the stable CSC comparison result struct. No file or % UI side effects. + function A = computeCSC(curve, opts) - A = csc.core.dispatch("computeCSC", curve, opts); +%COMPUTECSC Compute CV/CT charge comparison and CSC for the CSC app. + + if nargin < 2 + opts = struct(); + end + opts = fillOptions(opts); + + A = struct(); + A.ok = false; + A.message = ''; + A.logMessage = ''; + A.mode = opts.mode; + A.scanRate = opts.scanRate; + A.area_cm2 = parsePositiveScalar(opts.area_cm2); + + if ~(isscalar(A.scanRate) && isfinite(A.scanRate) && A.scanRate > 0) + A.message = 'scan rate missing'; + A.logMessage = 'Compare skipped: scan rate missing.'; + return; + end + + if ~hasExactColumns(curve, {'T', 'Vf', 'Im'}) + A.message = 'Need T, Vf, Im'; + A.logMessage = 'Compare skipped: T/Vf/Im not all present.'; + return; + end + + t = exactColumn(curve, 'T'); + V = exactColumn(curve, 'Vf'); + I = exactColumn(curve, 'Im'); + + good = ~(isnan(t) | isnan(V) | isnan(I)); + t = t(good); + V = V(good); + I = I(good); + + if numel(t) < 2 + A.message = 'Not enough points'; + A.logMessage = 'Compare skipped: not enough valid points.'; + return; + end + + CT = computeCTCharge(t, V, I); + CV = computeCVCharge(t, V, I, A.scanRate); + if ~CT.ok + A.message = CT.message; + A.logMessage = 'Compare skipped: not enough valid points.'; + return; + end + if ~CV.ok + A.message = CV.message; + A.logMessage = 'Compare skipped: scan rate missing.'; + return; + end + + A.t = t; + A.Vf = V; + A.Im = I; + A.IcathDisp = CT.IcathDisp; + A.IanodDisp = CT.IanodDisp; + A.QctCath = CT.QctCath; + A.QctAnod = CT.QctAnod; + A.QctFull = CT.QctFull; + A.QcvCath = CV.QcvCath; + A.QcvAnod = CV.QcvAnod; + A.QcvFull = CV.QcvFull; + A.dtErr = CV.dtErr; + + switch A.mode + case 'Cathodic' + A.Qct = A.QctCath; + A.Qcv = A.QcvCath; + case 'Anodic' + A.Qct = A.QctAnod; + A.Qcv = A.QcvAnod; + otherwise + A.mode = 'Full'; + A.Qct = A.QctFull; + A.Qcv = A.QcvFull; + end + + A.diff_C = A.Qct - A.Qcv; + denom = max(abs(A.Qct), abs(A.Qcv)); + if denom == 0 + A.rel_pct = 0; + else + A.rel_pct = 100 * abs(A.diff_C) / denom; + end + + if isfinite(A.area_cm2) && A.area_cm2 > 0 + A.Qct_mC_cm2 = 1e3 * A.Qct / A.area_cm2; + A.Qcv_mC_cm2 = 1e3 * A.Qcv / A.area_cm2; + A.diff_mC_cm2 = 1e3 * A.diff_C / A.area_cm2; + else + A.Qct_mC_cm2 = NaN; + A.Qcv_mC_cm2 = NaN; + A.diff_mC_cm2 = NaN; + end + + A.ok = true; + A.message = 'OK'; +end + +function opts = fillOptions(opts) + if ~isfield(opts, 'mode') + opts.mode = 'Full'; + end + if ~isfield(opts, 'scanRate') + opts.scanRate = NaN; + end + if ~isfield(opts, 'area_cm2') + opts.area_cm2 = NaN; + end +end + +function tf = hasExactColumns(curve, names) + tf = isfield(curve, 'headers'); + if ~tf + return; + end + for k = 1:numel(names) + if ~any(strcmp(curve.headers, names{k})) + tf = false; + return; + end + end +end + +function col = exactColumn(curve, name) + idx = find(strcmp(curve.headers, name), 1); + if isempty(idx) + col = []; + else + col = curve.data(:, idx); + end +end + +function R = computeCTCharge(t, V, I) + R = struct(); + R.ok = false; + R.message = ''; + + if nargin < 3 || numel(t) < 2 || numel(V) < 2 || numel(I) < 2 + R.message = 'Not enough points'; + R = fillEmptyCT(R); + return; + end + + S = integrateCVCTSignSplit(t, V, I, NaN); + R = copyFields(R, S, {'QctCath', 'QctAnod', 'IcathDisp', 'IanodDisp'}); + R.QctFull = R.QctCath + R.QctAnod; + R.ok = true; + R.message = 'OK'; +end + +function R = integrateCVCTSignSplit(t, V, I, scanRate) + if nargin < 4 + scanRate = NaN; + end + + t = t(:); + V = V(:); + I = I(:); + + R = struct(); + R.QctCath = 0; + R.QctAnod = 0; + R.QcvCath = 0; + R.QcvAnod = 0; + R.dtErr = NaN; + + R.IcathDisp = I; + R.IanodDisp = I; + R.IcathDisp(I >= 0) = NaN; + R.IanodDisp(I <= 0) = NaN; + + dtErrList = []; + useCV = isscalar(scanRate) && isfinite(scanRate) && scanRate > 0; + + for k = 1:numel(t)-1 + t1 = t(k); t2 = t(k+1); + V1 = V(k); V2 = V(k+1); + I1 = I(k); I2 = I(k+1); + + if any(~isfinite([t1 t2 V1 V2 I1 I2])) + continue; + end + + bp = [0, 1]; + s0 = crossingFraction(I1, I2, 0); + if ~isempty(s0) + bp(end+1) = s0; %#ok + end + bp = unique(sort(bp)); + + for j = 1:numel(bp)-1 + sa = bp(j); + sb = bp(j+1); + + ta = lerp(t1, t2, sa); + tb = lerp(t1, t2, sb); + Va = lerp(V1, V2, sa); + Vb = lerp(V1, V2, sb); + Ia = lerp(I1, I2, sa); + Ib = lerp(I1, I2, sb); + + Imid = 0.5 * (Ia + Ib); + if Imid < 0 + R.QctCath = R.QctCath + abs(trapz([ta tb], [Ia Ib])); + elseif Imid > 0 + R.QctAnod = R.QctAnod + trapz([ta tb], [Ia Ib]); + end + + if useCV + dt_act = tb - ta; + dt_cv = abs(Vb - Va) / scanRate; + dtErrList(end+1) = abs(dt_act - dt_cv); %#ok + + if Imid < 0 + R.QcvCath = R.QcvCath + abs(trapz([0 dt_cv], [Ia Ib])); + elseif Imid > 0 + R.QcvAnod = R.QcvAnod + trapz([0 dt_cv], [Ia Ib]); + end + end + end + end + + if ~isempty(dtErrList) + R.dtErr = max(dtErrList); + end +end + +function y = lerp(a, b, s) + y = a + s * (b - a); +end + +function s = crossingFraction(y1, y2, y0) + if ~isfinite(y1) || ~isfinite(y2) || y1 == y2 + s = []; + return; + end + s = (y0 - y1) / (y2 - y1); + if ~(s > 0 && s < 1) + s = []; + end +end + +function R = fillEmptyCT(R) + R.QctCath = 0; + R.QctAnod = 0; + R.QctFull = 0; + R.IcathDisp = []; + R.IanodDisp = []; +end + +function out = copyFields(out, in, names) + for k = 1:numel(names) + out.(names{k}) = in.(names{k}); + end +end + +function R = computeCVCharge(t, V, I, scanRate) + R = struct(); + R.ok = false; + R.message = ''; + + if nargin < 4 || ~(isscalar(scanRate) && isfinite(scanRate) && scanRate > 0) + R.message = 'scan rate missing'; + R = fillEmptyCV(R); + return; + end + if numel(t) < 2 || numel(V) < 2 || numel(I) < 2 + R.message = 'Not enough points'; + R = fillEmptyCV(R); + return; + end + + S = integrateCVCTSignSplit(t, V, I, scanRate); + R = copyFields(R, S, {'QcvCath', 'QcvAnod', 'dtErr', 'IcathDisp', 'IanodDisp'}); + R.QcvFull = R.QcvCath + R.QcvAnod; + R.ok = true; + R.message = 'OK'; +end + +function R = fillEmptyCV(R) + R.QcvCath = 0; + R.QcvAnod = 0; + R.QcvFull = 0; + R.dtErr = NaN; + R.IcathDisp = []; + R.IanodDisp = []; +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 diff --git a/apps/electrochem/eis/+eis/+core/dispatch.m b/apps/electrochem/eis/+eis/+core/dispatch.m deleted file mode 100644 index 4bd14bf..0000000 --- a/apps/electrochem/eis/+eis/+core/dispatch.m +++ /dev/null @@ -1,234 +0,0 @@ -% App-owned EIS helper core dispatch. Expected caller: labkit_EIS_app -% callbacks and package tests. Inputs are a command -% string plus the original helper arguments; outputs match the selected helper. -% This helper has no file side effects. -function varargout = dispatch(command, varargin) -%DISPATCH Route EIS package wrapper calls to app-owned helpers. -% Expected caller: labkit_EIS_app callbacks and package tests. Inputs are a -% command string plus the original helper arguments. -% Outputs match the selected helper. This helper has no file side effects. - - switch string(command) - case "labelForAxis" - varargout{1} = labelForAxis(varargin{:}); - case "buildSummary" - varargout{1} = buildSummary(varargin{:}); - case "plotOverlay" - varargout{1} = plotOverlay(varargin{:}); - case "buildExportTable" - varargout{1} = buildExportTable(varargin{:}); - case "valuesForAxis" - varargout{1} = valuesForAxis(varargin{:}); - otherwise - error('labkit:EIS:UnknownWorkflowCommand', ... - 'Unknown EIS workflow helper command: %s.', command); - end -end -function txt = labelForAxis(axisName) - txt = axisName; -end - -function summary = buildSummary(items) - summary = cell(0, 1); - summary{end+1} = sprintf('Loaded files: %d', numel(items)); - for i = 1:numel(items) - fmin = min(items(i).Freq, [], 'omitnan'); - fmax = max(items(i).Freq, [], 'omitnan'); - summary{end+1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... - items(i).name, items(i).n, fmin, fmax, ternary(items(i).freqDesc, 'high->low', 'low->high/mixed')); - end -end - -function labels = plotOverlay(ax, items, opts) - if nargin < 3 - opts = struct(); - end - opts = fillPlotOptions(opts); - - cla(ax); - ax.XScale = ternary(opts.logX, 'log', 'linear'); - ax.YScale = ternary(opts.logY, 'log', 'linear'); - axis(ax, 'normal'); - - cmap = lines(numel(items)); - labels = cell(1, numel(items)); - marker = 'none'; - if opts.showMarkers - marker = 'o'; - end - - hold(ax, 'on'); - for k = 1:numel(items) - [x, y] = filteredXY(items(k), opts.xName, opts.yName, opts.logX, opts.logY); - plot(ax, x, y, ... - 'LineWidth', opts.lineWidth, ... - 'Marker', marker, ... - 'MarkerSize', opts.markerSize, ... - 'Color', cmap(k, :)); - labels{k} = items(k).name; - end - hold(ax, 'off'); - - xlabel(ax, labelForAxis(opts.xName)); - ylabel(ax, labelForAxis(opts.yName)); - title(ax, sprintf('%s vs %s (%d file%s)', ... - labelForAxis(opts.yName), labelForAxis(opts.xName), numel(items), pluralS(numel(items)))); - - if opts.showGrid - grid(ax, 'on'); - else - grid(ax, 'off'); - end - - if opts.showLegend - legend(ax, labels, 'Interpreter', 'none', 'Location', 'best'); - else - legend(ax, 'off'); - end - - if isNyquistSelection(opts.xName, opts.yName) - axis(ax, 'equal'); - end -end - -function opts = fillPlotOptions(opts) - if ~isfield(opts, 'xName') - opts.xName = 'Zreal (ohm)'; - end - if ~isfield(opts, 'yName') - opts.yName = '-Zimag (ohm)'; - end - if ~isfield(opts, 'logX') - opts.logX = false; - end - if ~isfield(opts, 'logY') - opts.logY = false; - end - if ~isfield(opts, 'lineWidth') - opts.lineWidth = 1.4; - end - if ~isfield(opts, 'markerSize') - opts.markerSize = 6; - end - if ~isfield(opts, 'showMarkers') - opts.showMarkers = true; - end - if ~isfield(opts, 'showLegend') - opts.showLegend = true; - end - if ~isfield(opts, 'showGrid') - opts.showGrid = true; - end -end - -%% App-local export -function T = buildExportTable(items, xName, yName, useLogX, useLogY) - if nargin < 4 - useLogX = false; - end - if nargin < 5 - useLogY = false; - end - - maxLen = 0; - xCell = cell(1, numel(items)); - yCell = cell(1, numel(items)); - - for i = 1:numel(items) - [x, y] = filteredXY(items(i), xName, yName, useLogX, useLogY); - xCell{i} = x(:); - yCell{i} = y(:); - maxLen = max(maxLen, numel(x)); - end - - T = table((1:maxLen).', 'VariableNames', {'RowIndex'}); - for i = 1:numel(items) - safeName = matlab.lang.makeValidName(items(i).name); - xVar = matlab.lang.makeValidName(sprintf('X_%s_%s', sanitizeAxisName(xName), safeName)); - yVar = matlab.lang.makeValidName(sprintf('Y_%s_%s', sanitizeAxisName(yName), safeName)); - T.(xVar) = padWithNaN(xCell{i}, maxLen); - T.(yVar) = padWithNaN(yCell{i}, maxLen); - end -end - -%% Small app-local utilities -function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) - x = valuesForAxis(item, xName); - y = valuesForAxis(item, yName); - valid = isfinite(x) & isfinite(y); - x = x(valid); - y = y(valid); - if useLogX - validX = x > 0; - x = x(validX); - y = y(validX); - end - if useLogY - validY = y > 0; - x = x(validY); - y = y(validY); - end -end - -function values = valuesForAxis(item, axisName) - switch axisName - case 'Freq (Hz)' - values = item.Freq; - case 'log10(Freq)' - values = log10(item.Freq); - case 'Time (s)' - values = item.Time; - case 'Point #' - values = item.Pt; - case 'Zreal (ohm)' - values = item.Zreal; - case 'Zimag (ohm)' - values = item.Zimag; - case '-Zimag (ohm)' - values = item.negZimag; - case 'Zmod (ohm)' - values = item.Zmod; - case 'Zphz (deg)' - values = item.Zphz; - case 'Idc (A)' - values = item.Idc; - case 'Vdc (V)' - values = item.Vdc; - otherwise - error('Unsupported axis selection: %s', axisName); - end -end - -function padded = padWithNaN(v, n) - padded = NaN(n, 1); - if isempty(v) - return; - end - padded(1:numel(v)) = v(:); -end - -function out = sanitizeAxisName(txt) - out = regexprep(lower(txt), '[^a-z0-9]+', '_'); - out = regexprep(out, '^_+|_+$', ''); -end - -function tf = isNyquistSelection(xName, yName) - tf = strcmp(xName, 'Zreal (ohm)') && ... - (strcmp(yName, '-Zimag (ohm)') || strcmp(yName, 'Zimag (ohm)')); -end - -function txt = pluralS(n) - if n == 1 - txt = ''; - else - txt = 's'; - end -end - -function txt = ternary(cond, a, b) - if cond - txt = a; - else - txt = b; - end -end diff --git a/apps/electrochem/eis/+eis/+export/buildExportTable.m b/apps/electrochem/eis/+eis/+export/buildExportTable.m index 3eb06c9..4594de3 100644 --- a/apps/electrochem/eis/+eis/+export/buildExportTable.m +++ b/apps/electrochem/eis/+eis/+export/buildExportTable.m @@ -1,6 +1,92 @@ % Expected caller: EIS app runner and export tests. Inputs are EIS item structs, % axis labels, and log flags. Output is the stable EIS export table. No file side % effects. + function T = buildExportTable(items, xName, yName, useLogX, useLogY) - T = eis.core.dispatch("buildExportTable", items, xName, yName, useLogX, useLogY); + if nargin < 4 + useLogX = false; + end + if nargin < 5 + useLogY = false; + end + + maxLen = 0; + xCell = cell(1, numel(items)); + yCell = cell(1, numel(items)); + + for i = 1:numel(items) + [x, y] = filteredXY(items(i), xName, yName, useLogX, useLogY); + xCell{i} = x(:); + yCell{i} = y(:); + maxLen = max(maxLen, numel(x)); + end + + T = table((1:maxLen).', 'VariableNames', {'RowIndex'}); + for i = 1:numel(items) + safeName = matlab.lang.makeValidName(items(i).name); + xVar = matlab.lang.makeValidName(sprintf('X_%s_%s', sanitizeAxisName(xName), safeName)); + yVar = matlab.lang.makeValidName(sprintf('Y_%s_%s', sanitizeAxisName(yName), safeName)); + T.(xVar) = padWithNaN(xCell{i}, maxLen); + T.(yVar) = padWithNaN(yCell{i}, maxLen); + end +end + +function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) + x = valuesForAxis(item, xName); + y = valuesForAxis(item, yName); + valid = isfinite(x) & isfinite(y); + x = x(valid); + y = y(valid); + if useLogX + validX = x > 0; + x = x(validX); + y = y(validX); + end + if useLogY + validY = y > 0; + x = x(validY); + y = y(validY); + end +end + +function values = valuesForAxis(item, axisName) + switch axisName + case 'Freq (Hz)' + values = item.Freq; + case 'log10(Freq)' + values = log10(item.Freq); + case 'Time (s)' + values = item.Time; + case 'Point #' + values = item.Pt; + case 'Zreal (ohm)' + values = item.Zreal; + case 'Zimag (ohm)' + values = item.Zimag; + case '-Zimag (ohm)' + values = item.negZimag; + case 'Zmod (ohm)' + values = item.Zmod; + case 'Zphz (deg)' + values = item.Zphz; + case 'Idc (A)' + values = item.Idc; + case 'Vdc (V)' + values = item.Vdc; + otherwise + error('Unsupported axis selection: %s', axisName); + end +end + +function padded = padWithNaN(v, n) + padded = NaN(n, 1); + if isempty(v) + return; + end + padded(1:numel(v)) = v(:); +end + +function out = sanitizeAxisName(txt) + out = regexprep(lower(txt), '[^a-z0-9]+', '_'); + out = regexprep(out, '^_+|_+$', ''); end diff --git a/apps/electrochem/eis/+eis/+ops/valuesForAxis.m b/apps/electrochem/eis/+eis/+ops/valuesForAxis.m index bcceb12..8feb8ec 100644 --- a/apps/electrochem/eis/+eis/+ops/valuesForAxis.m +++ b/apps/electrochem/eis/+eis/+ops/valuesForAxis.m @@ -1,5 +1,31 @@ % Expected caller: EIS app runner and unit tests. Inputs are an EIS item struct % and axis label. Output is the selected numeric vector. No side effects. + function values = valuesForAxis(item, axisName) - values = eis.core.dispatch("valuesForAxis", item, axisName); + switch axisName + case 'Freq (Hz)' + values = item.Freq; + case 'log10(Freq)' + values = log10(item.Freq); + case 'Time (s)' + values = item.Time; + case 'Point #' + values = item.Pt; + case 'Zreal (ohm)' + values = item.Zreal; + case 'Zimag (ohm)' + values = item.Zimag; + case '-Zimag (ohm)' + values = item.negZimag; + case 'Zmod (ohm)' + values = item.Zmod; + case 'Zphz (deg)' + values = item.Zphz; + case 'Idc (A)' + values = item.Idc; + case 'Vdc (V)' + values = item.Vdc; + otherwise + error('Unsupported axis selection: %s', axisName); + end end diff --git a/apps/electrochem/eis/+eis/+view/buildSummary.m b/apps/electrochem/eis/+eis/+view/buildSummary.m index bbd2b96..8c0fff1 100644 --- a/apps/electrochem/eis/+eis/+view/buildSummary.m +++ b/apps/electrochem/eis/+eis/+view/buildSummary.m @@ -1,5 +1,21 @@ % Expected caller: EIS app runner. Input is EIS item structs. Output is the % stable summary text cell array. No side effects. + function summary = buildSummary(items) - summary = eis.core.dispatch("buildSummary", items); + summary = cell(0, 1); + summary{end+1} = sprintf('Loaded files: %d', numel(items)); + for i = 1:numel(items) + fmin = min(items(i).Freq, [], 'omitnan'); + fmax = max(items(i).Freq, [], 'omitnan'); + summary{end+1} = sprintf('%s | N=%d | Freq %.4g to %.4g Hz | order: %s', ... + items(i).name, items(i).n, fmin, fmax, ternary(items(i).freqDesc, 'high->low', 'low->high/mixed')); + end +end + +function txt = ternary(cond, a, b) + if cond + txt = a; + else + txt = b; + end end diff --git a/apps/electrochem/eis/+eis/+view/labelForAxis.m b/apps/electrochem/eis/+eis/+view/labelForAxis.m index 51ed84b..47d9320 100644 --- a/apps/electrochem/eis/+eis/+view/labelForAxis.m +++ b/apps/electrochem/eis/+eis/+view/labelForAxis.m @@ -1,5 +1,6 @@ % Expected caller: EIS app runner. Input is an axis selection label. Output is % the display label used by the app. No side effects. + function txt = labelForAxis(axisName) - txt = eis.core.dispatch("labelForAxis", axisName); + txt = axisName; end diff --git a/apps/electrochem/eis/+eis/+view/plotOverlay.m b/apps/electrochem/eis/+eis/+view/plotOverlay.m index 0889a51..0713a22 100644 --- a/apps/electrochem/eis/+eis/+view/plotOverlay.m +++ b/apps/electrochem/eis/+eis/+view/plotOverlay.m @@ -1,5 +1,156 @@ % Expected caller: EIS app runner. Inputs are an axes, EIS items, and plot % options. Output is legend labels. Side effects are limited to redrawing axes. + function labels = plotOverlay(ax, items, opts) - labels = eis.core.dispatch("plotOverlay", ax, items, opts); + if nargin < 3 + opts = struct(); + end + opts = fillPlotOptions(opts); + + cla(ax); + ax.XScale = ternary(opts.logX, 'log', 'linear'); + ax.YScale = ternary(opts.logY, 'log', 'linear'); + axis(ax, 'normal'); + + cmap = lines(numel(items)); + labels = cell(1, numel(items)); + marker = 'none'; + if opts.showMarkers + marker = 'o'; + end + + hold(ax, 'on'); + for k = 1:numel(items) + [x, y] = filteredXY(items(k), opts.xName, opts.yName, opts.logX, opts.logY); + plot(ax, x, y, ... + 'LineWidth', opts.lineWidth, ... + 'Marker', marker, ... + 'MarkerSize', opts.markerSize, ... + 'Color', cmap(k, :)); + labels{k} = items(k).name; + end + hold(ax, 'off'); + + xlabel(ax, labelForAxis(opts.xName)); + ylabel(ax, labelForAxis(opts.yName)); + title(ax, sprintf('%s vs %s (%d file%s)', ... + labelForAxis(opts.yName), labelForAxis(opts.xName), numel(items), pluralS(numel(items)))); + + if opts.showGrid + grid(ax, 'on'); + else + grid(ax, 'off'); + end + + if opts.showLegend + legend(ax, labels, 'Interpreter', 'none', 'Location', 'best'); + else + legend(ax, 'off'); + end + + if isNyquistSelection(opts.xName, opts.yName) + axis(ax, 'equal'); + end +end + +function txt = labelForAxis(axisName) + txt = axisName; +end + +function opts = fillPlotOptions(opts) + if ~isfield(opts, 'xName') + opts.xName = 'Zreal (ohm)'; + end + if ~isfield(opts, 'yName') + opts.yName = '-Zimag (ohm)'; + end + if ~isfield(opts, 'logX') + opts.logX = false; + end + if ~isfield(opts, 'logY') + opts.logY = false; + end + if ~isfield(opts, 'lineWidth') + opts.lineWidth = 1.4; + end + if ~isfield(opts, 'markerSize') + opts.markerSize = 6; + end + if ~isfield(opts, 'showMarkers') + opts.showMarkers = true; + end + if ~isfield(opts, 'showLegend') + opts.showLegend = true; + end + if ~isfield(opts, 'showGrid') + opts.showGrid = true; + end +end + +function [x, y] = filteredXY(item, xName, yName, useLogX, useLogY) + x = valuesForAxis(item, xName); + y = valuesForAxis(item, yName); + valid = isfinite(x) & isfinite(y); + x = x(valid); + y = y(valid); + if useLogX + validX = x > 0; + x = x(validX); + y = y(validX); + end + if useLogY + validY = y > 0; + x = x(validY); + y = y(validY); + end +end + +function values = valuesForAxis(item, axisName) + switch axisName + case 'Freq (Hz)' + values = item.Freq; + case 'log10(Freq)' + values = log10(item.Freq); + case 'Time (s)' + values = item.Time; + case 'Point #' + values = item.Pt; + case 'Zreal (ohm)' + values = item.Zreal; + case 'Zimag (ohm)' + values = item.Zimag; + case '-Zimag (ohm)' + values = item.negZimag; + case 'Zmod (ohm)' + values = item.Zmod; + case 'Zphz (deg)' + values = item.Zphz; + case 'Idc (A)' + values = item.Idc; + case 'Vdc (V)' + values = item.Vdc; + otherwise + error('Unsupported axis selection: %s', axisName); + end +end + +function tf = isNyquistSelection(xName, yName) + tf = strcmp(xName, 'Zreal (ohm)') && ... + (strcmp(yName, '-Zimag (ohm)') || strcmp(yName, 'Zimag (ohm)')); +end + +function txt = pluralS(n) + if n == 1 + txt = ''; + else + txt = 's'; + end +end + +function txt = ternary(cond, a, b) + if cond + txt = a; + else + txt = b; + end end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+core/dispatch.m b/apps/electrochem/vt_resistance/+vt_resistance/+core/dispatch.m deleted file mode 100644 index 736d938..0000000 --- a/apps/electrochem/vt_resistance/+vt_resistance/+core/dispatch.m +++ /dev/null @@ -1,542 +0,0 @@ -% App-owned VT resistance helper core dispatch. Expected caller: -% labkit_VTResistance_app callbacks and package 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 = dispatch(command, varargin) -%DISPATCH Route VT resistance package wrapper calls to app-owned helpers. -% Expected caller: labkit_VTResistance_app callbacks and % package 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. - - switch string(command) - case "computeResistance" - varargout{1} = computeResistance(varargin{:}); - case "buildBatchTableData" - varargout{1} = buildBatchTableData(varargin{:}); - case "buildResultsTable" - 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); - end -end -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/vt_resistance/+vt_resistance/+export/buildResultsTable.m b/apps/electrochem/vt_resistance/+vt_resistance/+export/buildResultsTable.m index ef9a06e..3e50ec5 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+export/buildResultsTable.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+export/buildResultsTable.m @@ -1,6 +1,79 @@ % Expected caller: VT resistance app runner and export tests. Input is item % structs. Output is the stable VT resistance CSV result table. No file side % effects. + function T = buildResultsTable(items) - T = vt_resistance.core.dispatch("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 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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+export/writeResultsCSV.m b/apps/electrochem/vt_resistance/+vt_resistance/+export/writeResultsCSV.m index 32c49eb..6197776 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+export/writeResultsCSV.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+export/writeResultsCSV.m @@ -1,5 +1,121 @@ % Expected caller: VT resistance app runner and export tests. Inputs are item % structs and output filepath. Side effect is writing the stable VT CSV file. + function [ok, msg] = writeResultsCSV(items, filepath) - [ok, msg] = vt_resistance.core.dispatch("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 + +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 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 s = csvEscape(x) + s = strrep(char(x), '"', '""'); end diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+ops/computeResistance.m b/apps/electrochem/vt_resistance/+vt_resistance/+ops/computeResistance.m index 88ba1fe..d9de01d 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+ops/computeResistance.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+ops/computeResistance.m @@ -1,6 +1,200 @@ % Expected caller: VT resistance app runner and unit tests. Inputs are a DTA item % struct and option struct. Output is the stable resistance result struct. No % file or UI side effects. + function A = computeResistance(item, opts) - A = vt_resistance.core.dispatch("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 + +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 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/vt_resistance/+vt_resistance/+ops/interp1Safe.m b/apps/electrochem/vt_resistance/+vt_resistance/+ops/interp1Safe.m index 441a02a..98a1843 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+ops/interp1Safe.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+ops/interp1Safe.m @@ -1,6 +1,21 @@ % Expected caller: VT resistance app plotting helpers. Inputs are vectors x/y and % query points. Output mirrors the app-owned safe interpolation helper. No side % effects. + function v = interp1Safe(x, y, xq) - v = vt_resistance.core.dispatch("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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceITAnnotations.m b/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceITAnnotations.m index 0132f42..288475d 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceITAnnotations.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceITAnnotations.m @@ -1,7 +1,45 @@ % Expected caller: VT resistance app plotting helpers. Inputs mirror the % app-owned current annotation helper. Side effects are limited to axes labels. -function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX) - vt_resistance.core.dispatch("addResistanceITAnnotations", ax, A, ... - cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... - cathStartX, cathEndX, anodStartX, anodEndX); + +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 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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceVTAnnotations.m b/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceVTAnnotations.m index 4287cd2..c1eb882 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceVTAnnotations.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+view/addResistanceVTAnnotations.m @@ -1,8 +1,91 @@ % Expected caller: VT resistance app plotting helpers. Inputs mirror the % app-owned voltage annotation helper. Side effects are limited to axes labels. -function addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX) - vt_resistance.core.dispatch("addResistanceVTAnnotations", ax, A, ... - cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... - cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... - cathStartX, cathEndX, anodStartX, anodEndX); + +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 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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+view/buildBatchTableData.m b/apps/electrochem/vt_resistance/+vt_resistance/+view/buildBatchTableData.m index fde6d3a..ba45be7 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+view/buildBatchTableData.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+view/buildBatchTableData.m @@ -1,5 +1,49 @@ % Expected caller: VT resistance app runner and unit tests. Input is item structs. % Output is the stable UI table cell data. No file side effects. + function C = buildBatchTableData(items) - C = vt_resistance.core.dispatch("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 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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+view/formatDurationUs.m b/apps/electrochem/vt_resistance/+vt_resistance/+view/formatDurationUs.m index a1ee40f..82408f1 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+view/formatDurationUs.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+view/formatDurationUs.m @@ -1,5 +1,10 @@ % Expected caller: VT resistance app runner. Input is a duration in seconds. % Output is the stable microsecond display text. No side effects. -function txt = formatDurationUs(value) - txt = vt_resistance.core.dispatch("formatDurationUs", value); + +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 diff --git a/apps/electrochem/vt_resistance/+vt_resistance/+view/shadeWindow.m b/apps/electrochem/vt_resistance/+vt_resistance/+view/shadeWindow.m index ee2a9d9..19d834d 100644 --- a/apps/electrochem/vt_resistance/+vt_resistance/+view/shadeWindow.m +++ b/apps/electrochem/vt_resistance/+vt_resistance/+view/shadeWindow.m @@ -1,9 +1,15 @@ % Expected caller: VT resistance app plotting helpers. Inputs are an axes, % x-window, color, and optional alpha. Side effects are limited to axes drawing. -function shadeWindow(ax, x1, x2, color, alpha) - if nargin < 5 - vt_resistance.core.dispatch("shadeWindow", ax, x1, x2, color); - else - vt_resistance.core.dispatch("shadeWindow", ax, x1, x2, color, alpha); + +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 diff --git a/docs/apps.md b/docs/apps.md index 7925451..eeb5ea1 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -120,8 +120,8 @@ tests. Tests should call the app-owned package function that owns the behavior. Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family and are not ready for a reusable `+labkit` facade. Existing DIC and wearable `private/` runners are migration debt, not the -preferred app structure. Electrochemistry `+core/dispatch.m` routers are also -temporary migration debt; do not add that routing layer to new app work. +preferred app structure. Do not add app-owned `+core/dispatch.m` string routers +to new app work. ## New App Checklist diff --git a/docs/architecture.md b/docs/architecture.md index fcd5f3d..888d415 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,20 +110,10 @@ Exit condition: migrate the remaining DIC and wearable app bodies/helpers into app-owned packages under their owning app folders, with public entry points owning GUI state, callbacks, debug launch routing, and user-facing log wording. -Allowed electrochemistry string-dispatch debt: - -```text -apps/electrochem/chrono_overlay/+chrono_overlay/+core/dispatch.m -apps/electrochem/cic/+cic/+core/dispatch.m -apps/electrochem/csc/+csc/+core/dispatch.m -apps/electrochem/eis/+eis/+core/dispatch.m -apps/electrochem/vt_resistance/+vt_resistance/+core/dispatch.m -``` - -Exit condition: replace these app-owned `+core/dispatch.m` string routers with -direct package functions or component-local implementation helpers. New apps -and new migrations should not add `private` runners, `*Workflow.m` adapters, or -additional `+core/dispatch.m` routing layers. +Allowed electrochemistry string-dispatch debt: none. The former app-owned +`+core/dispatch.m` routers have been replaced by component-local package +functions. New apps and new migrations should not add `private` runners, +`*Workflow.m` adapters, or `+core/dispatch.m` routing layers. ## Library Extraction Rule diff --git a/tests/integration/project/ProjectDebtGuardrailTest.m b/tests/integration/project/ProjectDebtGuardrailTest.m index 2ed8d8a..c0215c5 100644 --- a/tests/integration/project/ProjectDebtGuardrailTest.m +++ b/tests/integration/project/ProjectDebtGuardrailTest.m @@ -87,18 +87,11 @@ function appWorkflowDispatchDebtDoesNotGrow(testCase) ['String-dispatch workflow adapters should not be reintroduced. Files: ' ... strjoin(cellstr(workflowFiles), ', ')]); - expectedDispatchFiles = [ ... - "apps/electrochem/chrono_overlay/+chrono_overlay/+core/dispatch.m", ... - "apps/electrochem/cic/+cic/+core/dispatch.m", ... - "apps/electrochem/csc/+csc/+core/dispatch.m", ... - "apps/electrochem/eis/+eis/+core/dispatch.m", ... - "apps/electrochem/vt_resistance/+vt_resistance/+core/dispatch.m"]; dispatchFiles = collectRelativeFiles(root, ... fullfile(root, 'apps', '**', '+core', 'dispatch.m')); - unexpectedDispatchFiles = setdiff(dispatchFiles, expectedDispatchFiles); - testCase.verifyTrue(isempty(unexpectedDispatchFiles), ... - ['expected-debt: app-owned +core/dispatch.m debt grew. Files: ' ... - strjoin(cellstr(unexpectedDispatchFiles), ', ')]); + testCase.verifyTrue(isempty(dispatchFiles), ... + ['App-owned +core/dispatch.m string routers should not exist. Files: ' ... + strjoin(cellstr(dispatchFiles), ', ')]); fprintf('Workflow dispatch debt inventory: %d Workflow files, %d +core dispatch files.\n', ... numel(workflowFiles), numel(dispatchFiles)); diff --git a/tests/integration/project/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m index 5979753..6e29196 100644 --- a/tests/integration/project/ProjectStructureGuardrailTest.m +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -157,19 +157,19 @@ function electrochemAppsUseOwnedPackageNamespaces(testCase) assertElectrochemPackageLayout(testCase, root, ... 'chrono_overlay', 'chrono_overlay', ... - {'+core', '+export', '+ops', '+ui', '+view'}); + {'+export', '+ops', '+ui', '+view'}); assertElectrochemPackageLayout(testCase, root, ... 'cic', 'cic', ... - {'+core', '+export', '+ops', '+ui', '+view'}); + {'+export', '+ops', '+ui', '+view'}); assertElectrochemPackageLayout(testCase, root, ... 'csc', 'csc', ... - {'+core', '+ops', '+ui'}); + {'+ops', '+ui'}); assertElectrochemPackageLayout(testCase, root, ... 'eis', 'eis', ... - {'+core', '+export', '+ops', '+ui', '+view'}); + {'+export', '+ops', '+ui', '+view'}); assertElectrochemPackageLayout(testCase, root, ... 'vt_resistance', 'vt_resistance', ... - {'+core', '+export', '+ops', '+ui', '+view'}); + {'+export', '+ops', '+ui', '+view'}); end function sensitiveSampleHygieneScansTrackedText(testCase)