diff --git a/+labkit/+ui/+app/dispatchRequest.m b/+labkit/+ui/+app/dispatchRequest.m index 3d25648..5f35888 100644 --- a/+labkit/+ui/+app/dispatchRequest.m +++ b/+labkit/+ui/+app/dispatchRequest.m @@ -1,21 +1,18 @@ -function [handled, outputs, debugContext] = dispatchRequest(appName, args, nout, handlers) -%DISPATCHAPPREQUEST Dispatch app test/debug launch requests. +function [handled, outputs, debugContext] = dispatchRequest(appName, args, nout) +%DISPATCHREQUEST Dispatch app debug launch requests. % % Usage: % [handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... -% "labkit_Example_app", varargin, nargout, handlers); +% "labkit_Example_app", varargin, nargout); % % Inputs: % appName - app entry-point name used to build app-scoped error IDs. % args - input argument cell from the app entry point. % nout - requested output count from the app entry point. -% handlers - struct array with fields command, minArgs, maxArgs, -% maxOutputs, and run. The run function accepts command args as a cell -% array and returns outputs as a cell array. % % Outputs: -% handled - true when a "__labkit_test__" request was dispatched. -% outputs - cell array to assign to varargout for handled test requests. +% handled - false for normal and debug launches. +% outputs - empty cell array reserved for future launch request handlers. % debugContext - disabled for normal launches; enabled for "debug", % "-debug", "--debug", or "__labkit_debug__" launches. Debug launch % requests do not consume app launch. @@ -25,10 +22,6 @@ outputs = {}; debugContext = labkit.ui.diag.createContext(appName, struct('enabled', false)); - if nargin < 4 - handlers = struct('command', {}, 'minArgs', {}, ... - 'maxArgs', {}, 'maxOutputs', {}, 'run', {}); - end if isempty(args) return; end @@ -48,14 +41,8 @@ return; end - switch request - case "__labkit_test__" - handled = true; - outputs = dispatchTestRequest(appName, args(2:end), nout, handlers); - otherwise - error(errorId(appName, 'UnsupportedInput'), ... - '%s does not accept input arguments.', appName); - end + error(errorId(appName, 'UnsupportedInput'), ... + '%s does not accept input arguments.', appName); end function tf = isDebugRequest(request) @@ -65,13 +52,13 @@ function opts = debugOptions(appName, request, args) opts = struct(); if numel(args) > 2 - error(errorId(appName, 'InvalidTestRequest'), ... + error(errorId(appName, 'InvalidDebugOptions'), ... '%s accepts at most one options struct.', char(request)); elseif numel(args) == 2 opts = args{2}; end if ~isstruct(opts) - error(errorId(appName, 'InvalidTestRequest'), ... + error(errorId(appName, 'InvalidDebugOptions'), ... '%s options must be a struct.', char(request)); end opts.enabled = true; @@ -80,56 +67,6 @@ end end -function outputs = dispatchTestRequest(appName, requestArgs, nout, handlers) - if isempty(requestArgs) || ... - ~(ischar(requestArgs{1}) || (isstring(requestArgs{1}) && isscalar(requestArgs{1}))) - error(errorId(appName, 'InvalidTestRequest'), ... - '__labkit_test__ requires a string command name.'); - end - - validateHandlers(appName, handlers); - command = string(requestArgs{1}); - commandArgs = requestArgs(2:end); - match = find(strcmp(command, string({handlers.command})), 1, 'first'); - if isempty(match) - error(errorId(appName, 'UnknownTestCommand'), ... - 'Unknown __labkit_test__ command: %s.', command); - end - - handler = handlers(match); - argCount = numel(commandArgs); - if argCount < handler.minArgs || argCount > handler.maxArgs - error(errorId(appName, 'InvalidTestArguments'), ... - 'Command %s expects %d to %d argument(s), got %d.', ... - command, handler.minArgs, handler.maxArgs, argCount); - end - if nout > handler.maxOutputs - error(errorId(appName, 'TooManyOutputs'), ... - 'Command %s returns at most %d output(s).', command, handler.maxOutputs); - end - - outputs = handler.run(commandArgs); - if ~iscell(outputs) - error(errorId(appName, 'InvalidTestRequest'), ... - 'Command %s handler must return a cell array of outputs.', command); - end - if numel(outputs) < nout - error(errorId(appName, 'InvalidTestRequest'), ... - 'Command %s returned fewer outputs than requested.', command); - end - outputs = outputs(1:nout); -end - -function validateHandlers(appName, handlers) - required = {'command', 'minArgs', 'maxArgs', 'maxOutputs', 'run'}; - for k = 1:numel(required) - if ~isfield(handlers, required{k}) - error(errorId(appName, 'InvalidTestRequest'), ... - 'App test handler is missing field "%s".', required{k}); - end - end -end - function id = errorId(appName, suffix) id = sprintf('%s:%s', appName, suffix); end diff --git a/+labkit/+ui/+tool/createRuntime.m b/+labkit/+ui/+tool/createRuntime.m index 6ab61ef..4ddec77 100644 --- a/+labkit/+ui/+tool/createRuntime.m +++ b/+labkit/+ui/+tool/createRuntime.m @@ -277,14 +277,28 @@ function captureDrag(motionFcn, releaseFcn) state.fig.WindowButtonUpFcn = @onDragRelease; function onDragMotion(src, evt) - if ~isempty(motionFcn) - motionFcn(src, evt); + try + if ~isempty(motionFcn) + motionFcn(src, evt); + end + catch ME + trace(sprintf('drag motion error for session %s: %s', ... + char(sessionState.name), ME.identifier)); + releaseDrag(); + rethrow(ME); end end function onDragRelease(src, evt) - if ~isempty(releaseFcn) - releaseFcn(src, evt); + try + if ~isempty(releaseFcn) + releaseFcn(src, evt); + end + catch ME + trace(sprintf('drag release error for session %s: %s', ... + char(sessionState.name), ME.identifier)); + releaseDrag(); + rethrow(ME); end releaseDrag(); end diff --git a/+labkit/AGENTS.md b/+labkit/AGENTS.md index 6b8febd..07630bb 100644 --- a/+labkit/AGENTS.md +++ b/+labkit/AGENTS.md @@ -8,7 +8,7 @@ - `docs/ui.md` for `+labkit/+ui` - `docs/dta.md` for `+labkit/+dta` - `docs/biosignal.md` for `+labkit/+biosignal` -- affected package tests under `tests/suites/labkit/` +- affected package tests under `tests/unit/labkit/` or `tests/gui/structural/labkit/` ## Boundary Rules diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7815091 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +* text=auto + +*.fig binary +*.mat binary +*.mdl binary diff merge=mlAutoMerge +*.mdlp binary +*.mex* binary +*.mlapp binary +*.mldatx binary merge=mlAutoMerge +*.mlproj binary +*.mlx binary +*.p binary +*.plprj binary +*.sbproj binary +*.sfx binary +*.sldd binary +*.slreqx binary merge=mlAutoMerge +*.slmx binary merge=mlAutoMerge +*.sltx binary +*.slxc binary +*.slx binary merge=mlAutoMerge +*.slxp binary + +## MATLAB Project metadata files use LF line endings +/resources/project/**/*.xml text eol=lf + +## Other common binary file types +*.docx binary +*.exe binary +*.jpg binary +*.pdf binary +*.png binary +*.xlsx binary diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml index c4b5cb3..e1a2f5a 100644 --- a/.github/workflows/matlab-tests.yml +++ b/.github/workflows/matlab-tests.yml @@ -8,10 +8,166 @@ on: branches: - main workflow_dispatch: + schedule: + - cron: '17 10 * * 1' + +env: + MATLAB_RELEASE: R2025a jobs: - pure-matlab-tests: - name: Pure MATLAB Test Suite + quality: + name: Quality Guardrails + runs-on: ubuntu-latest + env: + MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v3 + with: + release: ${{ env.MATLAB_RELEASE }} + + - name: Prepare artifact directories + run: mkdir -p artifacts/logs artifacts/test-results/html + + - name: Run quality guardrails + uses: matlab-actions/run-build@v3 + with: + tasks: checkStyle + startup-options: -logfile artifacts/logs/matlab.log + + - name: Upload quality artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: matlab-quality + if-no-files-found: warn + retention-days: 14 + path: | + artifacts/test-results/junit.xml + artifacts/test-results/html/** + artifacts/logs/matlab.log + + unit: + name: Unit And Coverage + runs-on: ubuntu-latest + env: + MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v3 + with: + release: ${{ env.MATLAB_RELEASE }} + + - name: Prepare artifact directories + run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/coverage/html + + - name: Run unit tests and coverage + uses: matlab-actions/run-build@v3 + with: + tasks: testUnit coverage + startup-options: -logfile artifacts/logs/matlab.log + + - name: Upload unit and coverage artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: matlab-unit-coverage + if-no-files-found: warn + retention-days: 14 + path: | + artifacts/test-results/junit.xml + artifacts/test-results/html/** + artifacts/coverage/cobertura.xml + artifacts/coverage/html/** + artifacts/logs/matlab.log + + integration: + name: Integration Tests + runs-on: ubuntu-latest + env: + MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v3 + with: + release: ${{ env.MATLAB_RELEASE }} + + - name: Prepare artifact directories + run: mkdir -p artifacts/logs artifacts/test-results/html + + - name: Run integration tests + uses: matlab-actions/run-build@v3 + with: + tasks: testIntegration + startup-options: -logfile artifacts/logs/matlab.log + + - name: Upload integration artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: matlab-integration + if-no-files-found: warn + retention-days: 14 + path: | + artifacts/test-results/junit.xml + artifacts/test-results/html/** + artifacts/logs/matlab.log + + gui-structural: + name: GUI Structural Tests + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + runs-on: ubuntu-latest + env: + MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v3 + with: + release: ${{ env.MATLAB_RELEASE }} + + - name: Prepare artifact directories + run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/gui/trace artifacts/gui/snapshots + + - name: Run GUI structural tests + uses: matlab-actions/run-build@v3 + with: + tasks: testGuiStructural + startup-options: -logfile artifacts/logs/matlab.log + + - name: Upload GUI structural artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: matlab-gui-structural + if-no-files-found: warn + retention-days: 14 + path: | + artifacts/test-results/junit.xml + artifacts/test-results/html/** + artifacts/gui/trace/** + artifacts/gui/snapshots/** + artifacts/logs/matlab.log + + gui-gesture: + name: GUI Gesture Tests + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + continue-on-error: true runs-on: ubuntu-latest env: MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }} @@ -23,9 +179,27 @@ jobs: - name: Set up MATLAB uses: matlab-actions/setup-matlab@v3 with: - release: R2025a + release: ${{ env.MATLAB_RELEASE }} + + - name: Prepare artifact directories + run: mkdir -p artifacts/logs artifacts/test-results/html artifacts/gui/trace artifacts/gui/snapshots + + - name: Run GUI gesture tests + uses: matlab-actions/run-build@v3 + with: + tasks: testGuiGesture + startup-options: -logfile artifacts/logs/matlab.log - - name: Run pure MATLAB tests - uses: matlab-actions/run-command@v3 + - name: Upload GUI gesture artifacts + if: always() + uses: actions/upload-artifact@v4 with: - command: addpath(fullfile(pwd,'tests')); run_all_tests(false); + name: matlab-gui-gesture + if-no-files-found: warn + retention-days: 14 + path: | + artifacts/test-results/junit.xml + artifacts/test-results/html/** + artifacts/gui/trace/** + artifacts/gui/snapshots/** + artifacts/logs/matlab.log diff --git a/.gitignore b/.gitignore index 86ac49d..f317da6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store matlab_test.log matlab_test*.log +artifacts/ photos/ diff --git a/AGENTS.md b/AGENTS.md index 5fa4049..a29537f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ On Windows PowerShell: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run_matlab_tests.ps1 --suite project +matlab -batch "buildtool checkProject" +matlab -batch "buildtool packageDryRun" ``` Interactive GUI workflows are checked manually by the user. Do not run interactive GUI workflows in MATLAB `-batch` mode. If MATLAB cannot run, report the blocker and do not claim tests passed. diff --git a/LabKit.prj b/LabKit.prj new file mode 100644 index 0000000..6b95f98 --- /dev/null +++ b/LabKit.prj @@ -0,0 +1,2 @@ + + diff --git a/README.md b/README.md index f5ae8a1..ccc4eb0 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,20 @@ On Windows PowerShell: Focused checks are available during development: ```bash +buildtool checkProject +buildtool packageDryRun scripts/run_matlab_tests.sh --suite labkit/dta scripts/run_matlab_tests.sh --suite labkit/biosignal scripts/run_matlab_tests.sh --suite apps/wearable --gui scripts/run_matlab_tests.sh --suite labkit/ui --suite apps --gui ``` -The Windows script accepts the same `--suite`, `--test`, and `--gui` options. GitHub Actions runs the default non-GUI suite on pushes and pull requests to `main`. +The Windows script accepts the same `--suite`, `--test`, and `--gui` options. +`buildtool checkProject` verifies the MATLAB Project path/startup metadata, and +`buildtool packageDryRun` checks package boundaries without exporting a toolbox. +GitHub Actions runs quality, unit/coverage, and integration jobs on pushes and +pull requests to `main`; manual and scheduled runs also cover GUI structural and +non-blocking gesture jobs. ## Repository Layout diff --git a/apps/AGENTS.md b/apps/AGENTS.md index eb3b14c..a0c1a01 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -8,23 +8,26 @@ Apps are first-class deliverables. Do not treat them as examples for a hidden pl - `docs/ui.md` for layout, controls, axes, callbacks, or app shell changes - `docs/dta.md` for DTA-backed apps - `docs/biosignal.md` for wearable or biosignal-backed apps -- affected app tests under `tests/suites/apps/` +- affected app tests under `tests/unit/apps/` or `tests/gui/structural/apps/` ## App Ownership - Keep domain formulas, thresholds, integration rules, option defaults, plot labels, result fields, export columns, failed-row behavior, alerts, and log wording app-local unless the user explicitly approves a boundary change. - When a documented UI tool owns app-neutral controls or interaction mechanics, consume it instead of reimplementing widget state or normalization. Keep app calculations, summaries, alerts, and exports local. - Use `labkit.ui.app.createShell` for app GUIs. -- Use `labkit.ui.app.dispatchRequest` for internal test/debug launch routing and `labkit.ui.diag.createContext` only when an app has an app-specific nonstandard request path. +- Use `labkit.ui.app.dispatchRequest` for debug launch routing and `labkit.ui.diag.createContext` only when an app has an app-specific nonstandard request path. - Debug launches should attach the Log tab text area, emit a startup trace line, and instrument high-level component callbacks after controls are built. - Image apps with custom preview scroll, drawing, ROI, scale-bar, or other axes interaction should create a `labkit.ui.tool.createRuntime` and pass that runtime into reusable tools. Do not set image-tool `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or axes `ButtonDownFcn` directly in app code. - DTA-backed apps use `labkit.dta.*` for discovery, loading, sessions, pulse detection, and parsed curve/table access. - Biosignal-backed apps use `labkit.biosignal.*` for recording loading, channel extraction, waveform processing, events, segments, measurements, and group comparisons. - Do not create app-specific public helper packages to make local workflow code look reusable. - App-owned private helpers are acceptable only when they stay under the owning app tree and do not become public reusable APIs. +- Callback-heavy migrated apps may use an app-private runner to keep the public + launcher small, but the runner remains app-owned production code and must not + become a reusable facade. - When a public app file grows large, prefer moving GUI-free app-owned calculations, export builders, formatting utilities, and deterministic image/signal transforms into `apps///private/`. - Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family. -- Keep the public app entry point responsible for GUI state, callbacks, user alerts, app workflow order, `__labkit_test__` command routing, and user-facing log wording. +- Keep the public app entry point responsible for GUI state, callbacks, user alerts, app workflow order, debug launch routing, and user-facing log wording. ## Documentation Sync diff --git a/apps/dic/labkit_DICPostprocess_app.m b/apps/dic/labkit_DICPostprocess_app.m index 671df7d..f9282a5 100644 --- a/apps/dic/labkit_DICPostprocess_app.m +++ b/apps/dic/labkit_DICPostprocess_app.m @@ -353,232 +353,3 @@ function addLog(msg) debugLog.append(msg); end end - -function filepath = chooseImageFile(titleText) - [f, p] = uigetfile( ... - {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', 'Image files'; '*.*', 'All files'}, ... - titleText); - if isequal(f, 0) - filepath = ""; - else - filepath = string(fullfile(p, f)); - end -end - -function strain = loadNcorrStrain(matFile) - data = load(matFile, 'data_dic_save'); - if ~isfield(data, 'data_dic_save') || ~isfield(data.data_dic_save, 'strains') - error('MAT file must contain data_dic_save.strains.'); - end - - strains = data.data_dic_save.strains; - required = {'plot_exx_ref_formatted', 'plot_eyy_ref_formatted'}; - for i = 1:numel(required) - if ~isfield(strains, required{i}) - error('Missing data_dic_save.strains.%s.', required{i}); - end - end - - strain = struct(); - strain.exx = strains.plot_exx_ref_formatted; - strain.eyy = strains.plot_eyy_ref_formatted; - strain.roiMask = []; - if isfield(strains, 'roi_ref_formatted') && ... - isfield(strains.roi_ref_formatted, 'mask') - strain.roiMask = logical(strains.roi_ref_formatted.mask); - end -end - -function mask = imageMask(maskImage, targetSize) - if ndims(maskImage) == 3 - maskImage = rgb2gray(maskImage); - end - mask = maskImage > 128; - mask = imresize(mask, targetSize, 'nearest'); -end - -function targetSize = imageHeightWidth(imageData) - targetSize = [size(imageData, 1), size(imageData, 2)]; -end - -function overlay = makeStrainOverlay(referenceImage, strainMap, mask, roiMask, opts) - orig = enhanceReferenceImage(referenceImage, opts); - [H, W, ~] = size(orig); - mask = imresize(logical(mask), [H W], 'nearest'); - validMap = strainValidMask(strainMap, roiMask, mask); - [strainRgb, validStrain] = strainToRgb(strainMap, validMap, [H W], opts); - overlayMask = mask & validStrain; - mask3 = repmat(overlayMask, [1 1 3]); - overlay = orig; - overlay(mask3) = (1 - opts.alpha) .* orig(mask3) + opts.alpha .* strainRgb(mask3); -end - -function [rgb, validMask] = strainToRgb(strainMap, validMap, targetSize, opts) - S = extendStrainMapToRoi(double(strainMap), validMap); - if opts.sigmaSmooth > 0 - S = imgaussfilt(S, opts.sigmaSmooth); - end - Sbig = imresize(S, opts.oversample, 'lanczos3'); - Shr = imresize(Sbig, targetSize, 'lanczos3'); - validMask = imresize(logical(validMap), targetSize, 'nearest') & isfinite(Shr); - smin = opts.colorRange(1); - smax = opts.colorRange(2); - Snorm = (Shr - smin) ./ (smax - smin); - Snorm = max(min(Snorm, 1), 0); - idx = ones(size(Snorm)); - idx(validMask) = round(Snorm(validMask) * (size(opts.colormap, 1) - 1)) + 1; - rgb = ind2rgb(idx, opts.colormap); -end - -function validMap = strainValidMask(strainMap, roiMask, displayMask) - validMap = isfinite(strainMap); - if ~isempty(roiMask) - validMap = validMap & logical(roiMask); - else - validMap = validMap & imresize(logical(displayMask), size(strainMap), 'nearest'); - end -end - -function Sfilled = extendStrainMapToRoi(S, validMap) - validMap = logical(validMap) & isfinite(S); - Sfilled = S; - if ~any(validMap(:)) - Sfilled(:) = NaN; - return; - end - - [~, nearestIdx] = bwdist(validMap); - invalid = ~validMap; - Sfilled(invalid) = S(nearestIdx(invalid)); -end - -function img = enhanceReferenceImage(referenceImage, opts) - img = ensureRgb(im2double(referenceImage)); - gains = reshape(opts.rgbGain, 1, 1, 3); - img = img .* gains; - img = clamp01(img); - - hsvImage = rgb2hsv(img); - hsvImage(:, :, 2) = clamp01(hsvImage(:, :, 2) .* opts.saturation); - img = hsv2rgb(hsvImage); - - img = (img - 0.5) .* opts.contrast + 0.5 + opts.brightness; - img = clamp01(img); - img = img .^ opts.gamma; - img = clamp01(img); -end - -function x = clamp01(x) - x = min(max(x, 0), 1); -end - -function out = ensureRgb(imageData) - if ndims(imageData) == 2 - out = repmat(imageData, [1 1 3]); - else - out = imageData; - end -end - -function mask = summaryMaskForStrain(strain, overlayMask) - if ~isempty(strain.roiMask) - mask = logical(strain.roiMask); - else - mask = imresize(logical(overlayMask), size(strain.exx), 'nearest'); - end -end - -function T = summarizeStrain(strain, mask) - exx = strain.exx(mask); - eyy = strain.eyy(mask); - metric = ["Mean"; "Std"; "Median"; "Min"; "Max"]; - exxValues = nanSafeStats(exx); - eyyValues = nanSafeStats(eyy); - T = table(metric, exxValues, eyyValues, ... - 'VariableNames', {'Metric', 'EXX', 'EYY'}); -end - -function values = nanSafeStats(x) - x = x(:); - x = x(isfinite(x)); - if isempty(x) - values = nan(5, 1); - return; - end - values = [mean(x); std(x); median(x); min(x); max(x)]; -end - -function data = summaryTableData(T) - if isempty(T) || height(T) == 0 - data = {}; - return; - end - data = [cellstr(T.Metric), num2cell(T.EXX), num2cell(T.EYY)]; -end - -function showImage(ax, imageData, titleText) - labkit.ui.view.draw(ax, 'image', imageData, titleText); -end - -function exportOverlayFigure(overlayImage, componentName, colorRange, resolution, outfile) - fig = figure('Visible', 'off'); - cleanup = onCleanup(@() close(fig)); - imshow(overlayImage); - title(sprintf('Strain %s', componentName)); - colormap(jet); - clim(colorRange); - cb = colorbar; - cb.Label.String = sprintf('Strain %s', componentName); - exportgraphics(fig, outfile, 'Resolution', resolution); -end - -function exportStrainColorbar(opts, outfile) - fig = figure('Visible', 'off', 'Position', [100 100 420 720]); - cleanup = onCleanup(@() close(fig)); - ax = axes(fig, 'Position', [0.18 0.08 0.24 0.86]); - levels = linspace(opts.colorRange(1), opts.colorRange(2), size(opts.colormap, 1)); - imagesc(ax, 1, levels, levels(:)); - set(ax, 'XTick', [], 'YDir', 'normal'); - ylabel(ax, 'Strain level'); - colormap(ax, opts.colormap); - clim(ax, opts.colorRange); - cb = colorbar(ax, 'Location', 'eastoutside'); - cb.Label.String = 'Strain level'; - exportgraphics(fig, outfile, 'Resolution', opts.exportResolution); -end - -function T = colorbarLevelsTable(opts) - n = size(opts.colormap, 1); - strainLevel = linspace(opts.colorRange(1), opts.colorRange(2), n).'; - red = opts.colormap(:, 1); - green = opts.colormap(:, 2); - blue = opts.colormap(:, 3); - T = table(strainLevel, red, green, blue, ... - 'VariableNames', {'StrainLevel', 'Red', 'Green', 'Blue'}); -end - -function tag = tagFromPath(filepath) - tokens = regexp(filepath, '(\d+(?:\.\d+)?mm)', 'tokens'); - if isempty(tokens) - tag = 'unknown_mm'; - else - tag = tokens{end}{1}; - end - tag = regexprep(tag, '[^A-Za-z0-9_.-]', '_'); -end - -function txt = displayPath(pathValue) - if strlength(pathValue) == 0 - txt = 'none'; - else - txt = char(pathValue); - end -end - -function txt = ternary(cond, trueText, falseText) - if cond - txt = trueText; - else - txt = falseText; - end -end diff --git a/apps/dic/labkit_DICPreprocess_app.m b/apps/dic/labkit_DICPreprocess_app.m index c6daac2..5872a2f 100644 --- a/apps/dic/labkit_DICPreprocess_app.m +++ b/apps/dic/labkit_DICPreprocess_app.m @@ -17,1208 +17,11 @@ 'labkit_DICPreprocess_app returns at most the app figure handle.'); end - S = struct(); - S.referencePath = ""; - S.movingPath = ""; - S.referenceImage = []; - S.movingImage = []; - S.currentReferenceImage = []; - S.currentMovingImage = []; - S.alignedImage = []; - S.cropReference = []; - S.cropMoving = []; - S.cropRect = []; - S.cropRoiTop = []; - S.cropRoiBottom = []; - S.cropRoiListeners = {}; - S.maskImage = []; - S.maskPoints = []; - S.maskEditor = []; - S.maskBoundaryStyle = "Curve"; - S.maskEditActive = false; - S.maskHistory = struct('maskImage', {}, 'maskPoints', {}, 'description', {}); - S.history = struct('reference', {}, 'moving', {}, 'aligned', {}, ... - 'cropReference', {}, 'cropMoving', {}, 'maskImage', {}, ... - 'maskPoints', {}, 'description', {}); - - workbenchOpts = struct('rightKind', 'dualPlot', ... - 'rightTitle', 'Image Preview', ... - 'topPlotTitle', 'Reference', ... - 'bottomPlotTitle', 'Current Preview', ... - 'showPlotControls', false); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... - {240, 210, 330, 170}, ... - struct('resizeRows', [1 2 3], ... - 'resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 80))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {150, '1x'}, ... - struct('resizeRows', 1, ... - 'resizeOptions', struct('minTopHeight', 90, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'DIC Image Preprocess', ... - 'position', [80 60 1400 860], ... - 'leftWidth', 370, ... - 'options', workbenchOpts)); - fig = ui.fig; - imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... - struct('figure', fig, 'defaultScrollFcn', @onPreviewScrollZoom)); - - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - filePanel = labkit.ui.view.section(layFA, 'Images', 1, [4 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - fileGrid = filePanel.grid; - - btnReference = uibutton(fileGrid, 'Text', 'Open reference image', ... - 'ButtonPushedFcn', @onOpenReference); - btnReference.Layout.Row = 1; - btnReference.Layout.Column = 1; - btnMoving = uibutton(fileGrid, 'Text', 'Open moving image', ... - 'ButtonPushedFcn', @onOpenMoving); - btnMoving.Layout.Row = 1; - btnMoving.Layout.Column = 2; - - txtReference = labkit.ui.view.form(fileGrid, 'readonly', ... - 'Value', 'No reference image loaded'); - txtReference.Layout.Row = 2; - txtReference.Layout.Column = [1 2]; - txtMoving = labkit.ui.view.form(fileGrid, 'readonly', ... - 'Value', 'No moving image loaded'); - txtMoving.Layout.Row = 3; - txtMoving.Layout.Column = [1 2]; - - [lblPreview, ddPreview] = labkit.ui.view.form(fileGrid, 'dropdown', 'Preview:', ... - 'Items', {'Current pair', 'Current moving image', 'False-color overlay', 'Original pair', 'ROI mask'}, ... - 'Value', 'Current pair', ... - 'ValueChangedFcn', @(~,~) refreshPreview()); - lblPreview.Layout.Row = 4; - lblPreview.Layout.Column = 1; - ddPreview.Layout.Row = 4; - ddPreview.Layout.Column = 2; - - actionPanel = labkit.ui.view.section(layFA, 'Registration + Crop', 2, [6 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - actionGrid = actionPanel.grid; - - btnAlign = uibutton(actionGrid, 'Text', 'Select points + align', ... - 'ButtonPushedFcn', @onAlign); - btnAlign.Layout.Row = 1; - btnAlign.Layout.Column = [1 2]; - btnAutoAlign = uibutton(actionGrid, 'Text', 'Auto align current pair', ... - 'ButtonPushedFcn', @onAutoAlign); - btnAutoAlign.Layout.Row = 2; - btnAutoAlign.Layout.Column = [1 2]; - btnCrop = uibutton(actionGrid, 'Text', 'Start/reset crop ROI', ... - 'ButtonPushedFcn', @onStartCropRoi); - btnCrop.Layout.Row = 3; - btnCrop.Layout.Column = [1 2]; - btnApplyCrop = uibutton(actionGrid, 'Text', 'Apply ROI crop', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onApplyCropRoi); - btnApplyCrop.Layout.Row = 4; - btnApplyCrop.Layout.Column = 1; - btnCancelCrop = uibutton(actionGrid, 'Text', 'Cancel ROI', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onCancelCropRoi); - btnCancelCrop.Layout.Row = 4; - btnCancelCrop.Layout.Column = 2; - btnUndoEdit = uibutton(actionGrid, 'Text', 'Undo align/crop', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onUndoEdit); - btnUndoEdit.Layout.Row = 5; - btnUndoEdit.Layout.Column = 1; - btnSaveCurrent = uibutton(actionGrid, 'Text', 'Save current images', ... - 'ButtonPushedFcn', @onSaveCurrentImages); - btnSaveCurrent.Layout.Row = 5; - btnSaveCurrent.Layout.Column = 2; - btnResetCurrent = uibutton(actionGrid, 'Text', 'Reset to originals', ... - 'ButtonPushedFcn', @onResetToOriginals); - btnResetCurrent.Layout.Row = 6; - btnResetCurrent.Layout.Column = [1 2]; - maskPanel = labkit.ui.view.section(layFA, 'Mask ROI', 3, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{'1x', '1x'}})); - maskGrid = maskPanel.grid; - - btnStartMask = uibutton(maskGrid, 'Text', 'Start ROI edit', ... - 'ButtonPushedFcn', @onStartMaskEdit); - btnStartMask.Layout.Row = 1; - btnStartMask.Layout.Column = [1 2]; - [lblBoundaryStyle, ddBoundaryStyle] = labkit.ui.view.form(maskGrid, 'dropdown', 'Boundary:', ... - 'Items', {'Curve', 'Straight lines'}, ... - 'Value', 'Curve', ... - 'ValueChangedFcn', @onBoundaryStyleChanged); - lblBoundaryStyle.Layout.Row = 2; - lblBoundaryStyle.Layout.Column = 1; - ddBoundaryStyle.Layout.Row = 2; - ddBoundaryStyle.Layout.Column = 2; - ddBoundaryStyle.Enable = 'off'; - btnPreviewMask = uibutton(maskGrid, 'Text', 'Preview ROI mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onPreviewMaskRoi); - btnPreviewMask.Layout.Row = 3; - btnPreviewMask.Layout.Column = 1; - btnUnionMask = uibutton(maskGrid, 'Text', 'Add to mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onAddBoundaryToMask); - btnUnionMask.Layout.Row = 3; - btnUnionMask.Layout.Column = 2; - btnSubtractMask = uibutton(maskGrid, 'Text', 'Subtract from mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onSubtractBoundaryFromMask); - btnSubtractMask.Layout.Row = 4; - btnSubtractMask.Layout.Column = 1; - btnUndoMask = uibutton(maskGrid, 'Text', 'Undo point', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onUndoMaskAnchor); - btnUndoMask.Layout.Row = 4; - btnUndoMask.Layout.Column = 2; - btnUndoMaskEdit = uibutton(maskGrid, 'Text', 'Undo mask edit', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onUndoMaskEdit); - btnUndoMaskEdit.Layout.Row = 5; - btnUndoMaskEdit.Layout.Column = 1; - btnClearBoundary = uibutton(maskGrid, 'Text', 'Clear boundary', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onClearMaskBoundary); - btnClearBoundary.Layout.Row = 5; - btnClearBoundary.Layout.Column = 2; - btnClearMask = uibutton(maskGrid, 'Text', 'Clear mask', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onClearMaskCanvas); - btnClearMask.Layout.Row = 6; - btnClearMask.Layout.Column = [1 2]; - btnSaveMask = uibutton(maskGrid, 'Text', 'Save ROI mask', ... - 'ButtonPushedFcn', @onSaveMask); - btnSaveMask.Layout.Row = 7; - btnSaveMask.Layout.Column = [1 2]; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 4, { ... - '1. Load a reference image and a moving image.', ... - '2. Align or crop the current working pair in any order; each apply step can be undone.', ... - '3. False-color preview compares the current pair even before alignment.', ... - '4. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask.'}); - - txtSummary = uitextarea(laySR, 'Editable', 'off'); - txtSummary.Layout.Row = 1; - txtSummary.Value = {'No images loaded.'}; - - txtDetails = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtDetails, laySR, 2); - txtDetails.Value = {'Alignment and crop details will appear here.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('DIC preprocess debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetPreviewAxes(); - + fig = runDICPreprocessApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenReference(~, ~) - filepath = chooseImageFile('Select reference image'); - if filepath == "" - addLog('Reference image selection cancelled.'); - return; - end - S.referencePath = filepath; - S.referenceImage = imread(filepath); - S.currentReferenceImage = S.referenceImage; - resetWorkflowStateForNewInput(); - txtReference.Value = char(filepath); - addLog(sprintf('Loaded reference image: %s', filepath)); - chooseDefaultPreviewAfterLoad(); - refreshPreview(); - end - - function onOpenMoving(~, ~) - filepath = chooseImageFile('Select moving image'); - if filepath == "" - addLog('Moving image selection cancelled.'); - return; - end - S.movingPath = filepath; - S.movingImage = imread(filepath); - S.currentMovingImage = S.movingImage; - resetWorkflowStateForNewInput(); - txtMoving.Value = char(filepath); - addLog(sprintf('Loaded moving image: %s', filepath)); - chooseDefaultPreviewAfterLoad(); - refreshPreview(); - end - - function onAlign(~, ~) - if ~hasImagePair() - uialert(fig, 'Load both reference and moving images before alignment.', 'Missing images'); - return; - end - - addLog('Opening point selector. Choose matching points, then accept.'); - [movingPoints, fixedPoints] = cpselect(S.currentMovingImage, S.currentReferenceImage, 'Wait', true); - if size(movingPoints, 1) < 2 - uialert(fig, 'Rigid registration requires at least two point pairs.', 'Not enough points'); - addLog('Alignment cancelled: fewer than two point pairs.'); - return; - end - - pushHistory('manual alignment'); - [alignedImage, tform] = alignMovingToReference( ... - S.currentReferenceImage, S.currentMovingImage, fixedPoints, movingPoints); - S.currentMovingImage = alignedImage; - S.alignedImage = alignedImage; - clearOperationDerivedState(); - ddPreview.Value = 'False-color overlay'; - addLog(sprintf('Aligned image using %d point pair(s).', size(movingPoints, 1))); - txtDetails.Value = transformSummary(tform, size(S.currentReferenceImage), size(S.currentMovingImage)); - refreshPreview(); - end - - function onAutoAlign(~, ~) - if ~hasImagePair() - uialert(fig, 'Load both reference and moving images before automatic alignment.', 'Missing images'); - return; - end - - try - [alignedImage, tform, method] = autoAlignMovingToReference( ... - S.currentReferenceImage, S.currentMovingImage); - catch err - uialert(fig, sprintf('Automatic alignment failed:\n%s', err.message), 'Auto align failed'); - addLog(sprintf('Automatic alignment failed: %s', err.message)); - return; - end - - pushHistory('automatic alignment'); - S.currentMovingImage = alignedImage; - S.alignedImage = alignedImage; - clearOperationDerivedState(); - ddPreview.Value = 'False-color overlay'; - addLog(sprintf('Automatically aligned current pair using %s.', method)); - txtDetails.Value = transformSummary(tform, size(S.currentReferenceImage), size(S.currentMovingImage)); - refreshPreview(); - end - - function onStartCropRoi(~, ~) - if ~hasImagePair() - uialert(fig, 'Load both reference and moving images before cropping.', 'Missing images'); - return; - end - - clearCropRoi(); - clearMaskRoi(); - resetPreviewAxes(); - showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); - showImage(ui.bottomAxes, S.currentMovingImage, 'Current moving'); - - S.cropReference = []; - S.cropMoving = []; - rect = defaultSquareRect(size(S.currentReferenceImage)); - S.cropRect = rect; - S.cropRoiTop = drawrectangle(ui.topAxes, ... - 'Position', rect, ... - 'FixedAspectRatio', true, ... - 'Color', [1 0.85 0], ... - 'LineWidth', 1.5); - S.cropRoiBottom = rectangle(ui.bottomAxes, ... - 'Position', rect, ... - 'EdgeColor', [1 0.85 0], ... - 'LineWidth', 1.5, ... - 'LineStyle', '--'); - S.cropRoiListeners = { ... - addlistener(S.cropRoiTop, 'MovingROI', @onCropRoiMoved), ... - addlistener(S.cropRoiTop, 'ROIMoved', @onCropRoiMoved)}; - btnApplyCrop.Enable = 'on'; - btnCancelCrop.Enable = 'on'; - txtDetails.Value = cropSelectionSummary(rect); - addLog('Started crop ROI on the current pair preview.'); - refreshSummary(); - end - - function onApplyCropRoi(~, ~) - if isempty(S.cropRoiTop) || ~isvalid(S.cropRoiTop) - uialert(fig, 'Start a crop ROI before applying the crop.', 'No active ROI'); - return; - end - - rect = squareRectInsideImage(S.cropRoiTop.Position, size(S.currentReferenceImage)); - pushHistory('crop'); - S.cropRect = rect; - S.currentReferenceImage = imcrop(S.currentReferenceImage, rect); - S.currentMovingImage = imcrop(S.currentMovingImage, rect); - S.cropReference = S.currentReferenceImage; - S.cropMoving = S.currentMovingImage; - clearOperationDerivedState(); - clearCropRoi(); - ddPreview.Value = 'Current pair'; - showCurrentPair(); - addLog(sprintf('Cropped current pair with [%g %g %g %g].', ... - rect(1), rect(2), rect(3), rect(4))); - txtDetails.Value = cropSummary(rect); - refreshSummary(); - end - - function onCancelCropRoi(~, ~) - clearCropRoi(); - addLog('Crop ROI cancelled.'); - refreshPreview(); - end - - function onCropRoiMoved(~, evt) - if isprop(evt, 'CurrentPosition') - pos = evt.CurrentPosition; - else - pos = S.cropRoiTop.Position; - end - rect = squareRectInsideImage(pos, size(S.currentReferenceImage)); - S.cropRect = rect; - if ~isempty(S.cropRoiBottom) && isvalid(S.cropRoiBottom) - S.cropRoiBottom.Position = rect; - end - txtDetails.Value = cropSelectionSummary(rect); - end - - function onUndoEdit(~, ~) - if isempty(S.history) - uialert(fig, 'No align or crop operation is available to undo.', 'Undo'); - return; - end - - snapshot = S.history(end); - S.history(end) = []; - clearCropRoi(); - clearMaskRoi(); - S.currentReferenceImage = snapshot.reference; - S.currentMovingImage = snapshot.moving; - S.alignedImage = snapshot.aligned; - S.cropReference = snapshot.cropReference; - S.cropMoving = snapshot.cropMoving; - S.maskImage = snapshot.maskImage; - S.maskPoints = snapshot.maskPoints; - ddPreview.Value = 'Current pair'; - addLog(sprintf('Undid %s.', snapshot.description)); - txtDetails.Value = {sprintf('Restored state before %s.', snapshot.description)}; - refreshPreview(); - updateUndoButton(); - end - - function onResetToOriginals(~, ~) - if isempty(S.referenceImage) || isempty(S.movingImage) - uialert(fig, 'Load both images before resetting the working pair.', 'Reset'); - return; - end - pushHistory('reset to originals'); - S.currentReferenceImage = S.referenceImage; - S.currentMovingImage = S.movingImage; - S.alignedImage = []; - S.cropReference = []; - S.cropMoving = []; - clearCropRoi(); - clearMaskRoi(); - clearOperationDerivedState(); - ddPreview.Value = 'Current pair'; - addLog('Reset current working pair to the original loaded images.'); - txtDetails.Value = {'Current working pair reset to originals.'}; - refreshPreview(); - end - - function onSaveCurrentImages(~, ~) - if ~hasImagePair() - uialert(fig, 'Load both images before saving the current pair.', 'Save current images'); - return; - end - - folder = uigetdir(defaultSaveFolder(), 'Select folder for current images'); - if isequal(folder, 0) - addLog('Save current images cancelled.'); - return; - end - - refOut = fullfile(folder, 'current_reference.png'); - curOut = fullfile(folder, 'current_moving.png'); - imwrite(S.currentReferenceImage, refOut); - imwrite(S.currentMovingImage, curOut); - addLog(sprintf('Saved current images: %s and %s', refOut, curOut)); - end - - function onStartMaskEdit(~, ~) - if isempty(S.currentReferenceImage) - uialert(fig, 'Load a reference image before drawing an ROI mask.', 'Missing image'); - return; - end - - clearCropRoi(); - clearMaskRoi(); - resetPreviewAxes(); - hTopImage = showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); - showImage(ui.bottomAxes, zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 3, 'uint8'), 'ROI mask preview'); - S.maskImage = []; - S.maskPoints = []; - S.maskHistory = S.maskHistory([]); - S.maskBoundaryStyle = string(ddBoundaryStyle.Value); - S.maskEditor = labkit.ui.tool.anchorEditor(imageRuntime, size(S.currentReferenceImage), ... - struct('closed', true, ... - 'style', S.maskBoundaryStyle, ... - 'installScrollWheel', false, ... - 'onChanged', @onMaskEditorChanged)); - S.maskEditor.setBackground(hTopImage); - S.maskEditor.start(S.maskPoints); - setMaskEditControls(true); - addLog('Started mask ROI canvas. Add/insert, move, or delete anchors; add/subtract boundaries on the mask canvas.'); - txtDetails.Value = {'ROI edit started. Double-click blank space to add/insert points, drag points to move them, double-click points to delete them.'}; - updateMaskEditControls(); - end - - function onMaskEditorChanged(points, ~) - S.maskPoints = points; - updateMaskDraft(); - end - - function onBoundaryStyleChanged(~, ~) - S.maskBoundaryStyle = string(ddBoundaryStyle.Value); - if ~isempty(S.maskEditor) - S.maskEditor.setStyle(S.maskBoundaryStyle); - end - updateMaskCurveGraphics(); - txtDetails.Value = {sprintf('Boundary style: %s.', char(S.maskBoundaryStyle))}; - end - - function onUndoMaskAnchor(~, ~) - if ~isempty(S.maskEditor) - S.maskEditor.undoLast(); - end - end - - function onClearMaskBoundary(~, ~) - if ~isempty(S.maskEditor) - S.maskEditor.clearPoints(); - else - S.maskPoints = []; - updateMaskDraft(); - end - addLog('Cleared mask ROI boundary anchors.'); - end - - function onClearMaskCanvas(~, ~) - if isempty(S.maskImage) - return; - end - pushMaskHistory('clear mask canvas'); - S.maskImage = []; - showMaskCanvas('ROI mask canvas'); - addLog('Cleared ROI mask canvas.'); - refreshSummary(); - end - - function updateMaskDraft() - updateMaskCurveGraphics(); - updateMaskEditControls(); - if size(S.maskPoints, 1) >= 3 - txtDetails.Value = {sprintf('Mask ROI anchors: %d. Preview, Add to mask, or Subtract from mask.', size(S.maskPoints, 1))}; - else - txtDetails.Value = {sprintf('Mask ROI anchors: %d. Need at least 3 anchors to form a closed ROI boundary.', size(S.maskPoints, 1))}; - end - refreshSummary(); - end - - function updateMaskCurveGraphics() - if ~isempty(S.maskEditor) - S.maskEditor.refresh(); - end - end - - function setMaskEditControls(enabled) - S.maskEditActive = enabled; - state = ternary(enabled, 'on', 'off'); - ddBoundaryStyle.Enable = state; - updateMaskEditControls(); - end - - function updateMaskEditControls() - editActive = S.maskEditActive; - hasPoints = ~isempty(S.maskPoints); - canBoundary = size(S.maskPoints, 1) >= 3; - canUndoCanvas = ~isempty(S.maskHistory); - canClearCanvas = ~isempty(S.maskImage); - btnPreviewMask.Enable = ternary(editActive && (canBoundary || canClearCanvas), 'on', 'off'); - btnUnionMask.Enable = ternary(editActive && canBoundary, 'on', 'off'); - btnSubtractMask.Enable = ternary(editActive && canBoundary, 'on', 'off'); - btnUndoMask.Enable = ternary(editActive && hasPoints, 'on', 'off'); - btnClearBoundary.Enable = ternary(editActive && hasPoints, 'on', 'off'); - btnUndoMaskEdit.Enable = ternary(editActive && canUndoCanvas, 'on', 'off'); - btnClearMask.Enable = ternary(editActive && canClearCanvas, 'on', 'off'); - end - - function onPreviewMaskRoi(~, ~) - previewMaskImage(true); - end - - function onAddBoundaryToMask(~, ~) - [boundaryMask, ok] = currentBoundaryMask(true); - if ~ok - return; - end - pushMaskHistory('add boundary to mask'); - S.maskImage = max(maskCanvas(), boundaryMask); - showMaskCanvas('ROI mask canvas'); - addLog(sprintf('Added %s boundary to ROI mask canvas.', char(S.maskBoundaryStyle))); - refreshSummary(); - end - - function onSubtractBoundaryFromMask(~, ~) - [boundaryMask, ok] = currentBoundaryMask(true); - if ~ok - return; - end - pushMaskHistory('subtract boundary from mask'); - canvas = maskCanvas(); - canvas(boundaryMask > 0) = 0; - S.maskImage = canvas; - showMaskCanvas('ROI mask canvas'); - addLog(sprintf('Subtracted %s boundary from ROI mask canvas.', char(S.maskBoundaryStyle))); - refreshSummary(); - end - - function onUndoMaskEdit(~, ~) - if isempty(S.maskHistory) - return; - end - snapshot = S.maskHistory(end); - S.maskHistory(end) = []; - S.maskImage = snapshot.maskImage; - S.maskPoints = snapshot.maskPoints; - if ~isempty(S.maskEditor) - S.maskEditor.setPoints(S.maskPoints); - end - updateMaskCurveGraphics(); - showMaskCanvas('ROI mask canvas'); - addLog(sprintf('Undid mask edit: %s.', snapshot.description)); - refreshSummary(); - end - - function onPreviewScrollZoom(~, evt) - ax = previewAxesUnderPointer(); - if isempty(ax) - return; - end - - point = ax.CurrentPoint; - x = point(1, 1); - y = point(1, 2); - imageSize = axesImageSize(ax); - if isempty(imageSize) || ~insideImageBounds(x, y, imageSize) - return; - end - zoomAxesAtPoint(ax, x, y, evt.VerticalScrollCount, imageSize); - end - - function ax = previewAxesUnderPointer() - ax = []; - try - hit = hittest(fig); - ax = ancestor(hit, 'matlab.ui.control.UIAxes'); - catch - ax = []; - end - if isequal(ax, ui.topAxes) || isequal(ax, ui.bottomAxes) - return; - end - ax = []; - end - - function onSaveMask(~, ~) - if isempty(S.maskImage) - [boundaryMask, ok] = currentBoundaryMask(false); - if ~ok - uialert(fig, 'Draw a mask ROI or add a boundary to the mask canvas before saving.', 'Save ROI mask'); - return; - end - S.maskImage = boundaryMask; - end - - [folder, name] = fileparts(char(S.referencePath)); - if isempty(folder) - folder = pwd; - end - defaultName = fullfile(folder, [name '_roi_mask.png']); - [f, p] = uiputfile({'*.png', 'PNG mask'}, 'Save ROI mask', defaultName); - if isequal(f, 0) - addLog('Save ROI mask cancelled.'); - return; - end - - out = fullfile(p, f); - imwrite(S.maskImage, out); - addLog(sprintf('Saved ROI mask: %s', out)); - end - - function ok = previewMaskImage(showAlert) - [boundaryMask, ok] = currentBoundaryMask(showAlert); - if ok - ddPreview.Value = 'ROI mask'; - showImage(ui.bottomAxes, maskRgb(boundaryMask), 'ROI boundary preview'); - updateMaskCurveGraphics(); - addLog(sprintf('Previewed %s ROI boundary with %d anchors.', ... - char(S.maskBoundaryStyle), size(S.maskPoints, 1))); - txtDetails.Value = {'Boundary preview updated. Add it to the mask canvas, subtract it, or keep editing anchors.'}; - refreshSummary(); - return; - end - if ~isempty(S.maskImage) - ddPreview.Value = 'ROI mask'; - showMaskCanvas('ROI mask canvas'); - ok = true; - end - end - - function [boundaryMask, ok] = currentBoundaryMask(showAlert) - ok = false; - boundaryMask = []; - if size(S.maskPoints, 1) < 3 - if showAlert - uialert(fig, 'Mask ROI needs at least three anchors.', 'Not enough anchors'); - end - return; - end - if ~isempty(S.maskEditor) - curve = S.maskEditor.curvePoints(); - boundaryMask = maskFromCurve(curve, size(S.currentReferenceImage)); - else - boundaryMask = boundaryMaskImage(S.maskPoints, size(S.currentReferenceImage), S.maskBoundaryStyle); - end - ok = true; - end - - function canvas = maskCanvas() - if isempty(S.maskImage) - canvas = zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 'uint8'); - else - canvas = S.maskImage; - end - end - - function showMaskCanvas(titleText) - if isempty(S.maskImage) - mask = zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 'uint8'); - else - mask = S.maskImage; - end - ddPreview.Value = 'ROI mask'; - showImage(ui.bottomAxes, maskRgb(mask), titleText); - updateMaskEditControls(); - end - - function pushMaskHistory(description) - snapshot = struct( ... - 'maskImage', S.maskImage, ... - 'maskPoints', S.maskPoints, ... - 'description', description); - S.maskHistory(end+1) = snapshot; - maxUndoSteps = 20; - if numel(S.maskHistory) > maxUndoSteps - S.maskHistory = S.maskHistory((end - maxUndoSteps + 1):end); - end - updateMaskEditControls(); - end - - function refreshPreview() - clearCropRoi(); - clearMaskRoi(); - resetPreviewAxes(); - if strcmp(ddPreview.Value, 'Current pair') - showCurrentPair(); - refreshSummary(); - return; - elseif strcmp(ddPreview.Value, 'Original pair') - showOriginalPair(); - refreshSummary(); - return; - elseif strcmp(ddPreview.Value, 'ROI mask') - if ~isempty(S.currentReferenceImage) - showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); - end - if ~isempty(S.maskImage) - showImage(ui.bottomAxes, maskRgb(S.maskImage), 'ROI mask'); - end - refreshSummary(); - return; - elseif ~isempty(S.currentReferenceImage) - showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); - end - - previewImage = []; - previewTitle = ddPreview.Value; - switch ddPreview.Value - case 'Current moving image' - previewImage = S.currentMovingImage; - case 'False-color overlay' - if hasImagePair() - previewImage = makeFalseColorOverlay(S.currentReferenceImage, S.currentMovingImage); - end - end - - if ~isempty(previewImage) - showImage(ui.bottomAxes, previewImage, previewTitle); - end - refreshSummary(); - end - - function refreshSummary() - lines = {}; - lines{end+1} = sprintf('Reference: %s', displayPath(S.referencePath)); - lines{end+1} = sprintf('Moving: %s', displayPath(S.movingPath)); - lines{end+1} = sprintf('Current pair: %s', ternary(hasImagePair(), currentPairSizeText(), 'not loaded')); - lines{end+1} = sprintf('Undo steps: %d', numel(S.history)); - lines{end+1} = sprintf('Last aligned image: %s', ternary(~isempty(S.alignedImage), 'available', 'not generated')); - lines{end+1} = sprintf('ROI mask: %s', ternary(~isempty(S.maskImage), 'available', 'not drawn')); - txtSummary.Value = lines; - updateUndoButton(); - end - - function tf = hasImagePair() - tf = ~isempty(S.currentReferenceImage) && ~isempty(S.currentMovingImage); - end - - function txt = currentPairSizeText() - if ~hasImagePair() - txt = 'not loaded'; - return; - end - txt = sprintf('reference %d x %d, moving %d x %d', ... - size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), ... - size(S.currentMovingImage, 1), size(S.currentMovingImage, 2)); - end - - function showCurrentPair() - resetPreviewAxes(); - if ~isempty(S.currentReferenceImage) - showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); - end - if ~isempty(S.currentMovingImage) - showImage(ui.bottomAxes, S.currentMovingImage, 'Current moving'); - end - end - - function showOriginalPair() - resetPreviewAxes(); - if ~isempty(S.referenceImage) - showImage(ui.topAxes, S.referenceImage, 'Original reference'); - end - if ~isempty(S.movingImage) - showImage(ui.bottomAxes, S.movingImage, 'Original moving'); - end - end - - function clearCropRoi() - for iListener = 1:numel(S.cropRoiListeners) - deleteIfValid(S.cropRoiListeners{iListener}); - end - S.cropRoiListeners = {}; - deleteIfValid(S.cropRoiTop); - deleteIfValid(S.cropRoiBottom); - S.cropRoiTop = []; - S.cropRoiBottom = []; - btnApplyCrop.Enable = 'off'; - btnCancelCrop.Enable = 'off'; - end - - function clearMaskRoi() - if ~isempty(S.maskEditor) - S.maskEditor.delete(); - end - S.maskEditor = []; - S.maskPoints = []; - setMaskEditControls(false); - end - - function resetWorkflowStateForNewInput() - if ~isempty(S.referenceImage) - S.currentReferenceImage = S.referenceImage; - end - if ~isempty(S.movingImage) - S.currentMovingImage = S.movingImage; - end - S.alignedImage = []; - S.cropReference = []; - S.cropMoving = []; - S.cropRect = []; - S.maskImage = []; - S.maskPoints = []; - S.maskHistory = S.maskHistory([]); - S.history = S.history([]); - clearCropRoi(); - clearMaskRoi(); - updateUndoButton(); - end - - function chooseDefaultPreviewAfterLoad() - if hasImagePair() - ddPreview.Value = 'False-color overlay'; - else - ddPreview.Value = 'Current pair'; - end - end - - function pushHistory(description) - if ~hasImagePair() - return; - end - snapshot = struct( ... - 'reference', S.currentReferenceImage, ... - 'moving', S.currentMovingImage, ... - 'aligned', S.alignedImage, ... - 'cropReference', S.cropReference, ... - 'cropMoving', S.cropMoving, ... - 'maskImage', S.maskImage, ... - 'maskPoints', S.maskPoints, ... - 'description', description); - S.history(end+1) = snapshot; - maxUndoSteps = 12; - if numel(S.history) > maxUndoSteps - S.history = S.history((end - maxUndoSteps + 1):end); - end - updateUndoButton(); - end - - function clearOperationDerivedState() - S.maskImage = []; - S.maskPoints = []; - S.maskHistory = S.maskHistory([]); - clearMaskRoi(); - end - - function updateUndoButton() - btnUndoEdit.Enable = ternary(~isempty(S.history), 'on', 'off'); - end - - function folder = defaultSaveFolder() - [folder, ~] = fileparts(char(S.referencePath)); - if isempty(folder) - [folder, ~] = fileparts(char(S.movingPath)); - end - if isempty(folder) - folder = pwd; - end - end - - function resetPreviewAxes() - labkit.ui.view.draw(ui.topAxes, 'reset', 'Reference', true); - labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Current Preview', true); - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end -end - -function filepath = chooseImageFile(titleText) - [f, p] = uigetfile( ... - {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', 'Image files'; '*.*', 'All files'}, ... - titleText); - if isequal(f, 0) - filepath = ""; - else - filepath = string(fullfile(p, f)); - end -end - -function [alignedImage, tformRigid] = alignMovingToReference(referenceImage, movingImage, fixedPoints, movingPoints) - origClass = class(movingImage); - [~, ~, tr] = procrustes(fixedPoints, movingPoints, ... - 'Scaling', false, 'Reflection', false); - - R = tr.T; - t = tr.c(1, :); - T = [R(1,1) R(1,2) 0; ... - R(2,1) R(2,2) 0; ... - t(1) t(2) 1]; - tformRigid = affine2d(T); - - Rfixed = imref2d(size(referenceImage(:, :, 1))); - alignedImage = imwarp(movingImage, tformRigid, ... - 'OutputView', Rfixed, 'FillValues', 0); - alignedImage = cast(alignedImage, origClass); -end - -function [alignedImage, tformRigid, method] = autoAlignMovingToReference(referenceImage, movingImage) - origClass = class(movingImage); - fixedGray = normalizeGray(referenceImage); - movingGray = normalizeGray(movingImage); - - try - tformRigid = imregcorr(movingGray, fixedGray, 'rigid'); - method = 'phase-correlation rigid registration'; - catch - tformRigid = imregcorr(movingGray, fixedGray, 'translation'); - method = 'phase-correlation translation registration'; - end - - Rfixed = imref2d(size(fixedGray)); - alignedImage = imwarp(movingImage, tformRigid, ... - 'OutputView', Rfixed, 'FillValues', 0); - alignedImage = cast(alignedImage, origClass); -end - -function rect = defaultSquareRect(imageSize) - H = imageSize(1); - W = imageSize(2); - side = max(1, round(0.5 * min(H, W))); - x = round((W - side) / 2) + 1; - y = round((H - side) / 2) + 1; - rect = squareRectInsideImage([x y side side], imageSize); -end - -function rect = squareRectInsideImage(roi, imageSize) - x = roi(1); - y = roi(2); - w = roi(3); - h = roi(4); - side = round(max(w, h)); - side = max(side, 1); - maxSide = max(1, min(imageSize(1), imageSize(2)) - 1); - side = min(side, maxSide); - - cx = x + w / 2; - cy = y + h / 2; - xSq = round(cx - side / 2); - ySq = round(cy - side / 2); - - maxX = max(1, imageSize(2) - side); - maxY = max(1, imageSize(1) - side); - xSq = min(max(1, xSq), maxX); - ySq = min(max(1, ySq), maxY); - rect = [xSq, ySq, side, side]; -end - -function mask = boundaryMaskImage(points, imageSize, boundaryStyle) - curve = maskBoundaryCurve(points, imageSize, boundaryStyle); - mask = maskFromCurve(curve, imageSize); -end - -function mask = maskFromCurve(curve, imageSize) - H = imageSize(1); - W = imageSize(2); - if isempty(curve) - mask = uint8(false(H, W)); - return; - end - mask = uint8(poly2mask(curve(:, 1), curve(:, 2), H, W)) .* uint8(255); -end - -function curve = maskBoundaryCurve(points, imageSize, boundaryStyle) - if size(points, 1) < 3 - curve = []; - return; - end - if strcmp(string(boundaryStyle), "Straight lines") - curve = [points; points(1, :)]; - curve(:, 1) = min(max(curve(:, 1), 0.5), imageSize(2) + 0.5); - curve(:, 2) = min(max(curve(:, 2), 0.5), imageSize(1) + 0.5); - return; - end - - n = size(points, 1); - samplesPerSegment = max(12, ceil(240 / n)); - curve = zeros(n * samplesPerSegment + 1, 2); - out = 1; - for i = 1:n - p0 = points(wrapIndex(i - 1, n), :); - p1 = points(i, :); - p2 = points(wrapIndex(i + 1, n), :); - p3 = points(wrapIndex(i + 2, n), :); - for k = 0:(samplesPerSegment - 1) - t = k / samplesPerSegment; - curve(out, :) = catmullRomPoint(p0, p1, p2, p3, t); - out = out + 1; - end - end - curve(out, :) = curve(1, :); - curve = curve(1:out, :); - curve(:, 1) = min(max(curve(:, 1), 0.5), imageSize(2) + 0.5); - curve(:, 2) = min(max(curve(:, 2), 0.5), imageSize(1) + 0.5); -end - -function p = catmullRomPoint(p0, p1, p2, p3, t) - p = 0.5 .* ((2 .* p1) + ... - (-p0 + p2) .* t + ... - (2 .* p0 - 5 .* p1 + 4 .* p2 - p3) .* t.^2 + ... - (-p0 + 3 .* p1 - 3 .* p2 + p3) .* t.^3); -end - -function idx = wrapIndex(idx, n) - idx = mod(idx - 1, n) + 1; -end - -function tf = insideImageBounds(x, y, imageSize) - tf = isfinite(x) && isfinite(y) && ... - x >= 0.5 && y >= 0.5 && ... - x <= imageSize(2) + 0.5 && y <= imageSize(1) + 0.5; -end - -function rgb = maskRgb(maskImage) - rgb = repmat(maskImage, [1 1 3]); -end - -function lines = cropSelectionSummary(rect) - lines = { ... - sprintf('Active crop source: current reference and current moving images'), ... - sprintf('Move or resize the ROI on the current reference preview, then click Apply ROI crop.'), ... - sprintf('Current square ROI: x=%d, y=%d, size=%d px', ... - round(rect(1)), round(rect(2)), round(rect(3)))}; -end - -function hImage = showImage(ax, imageData, titleText) - hImage = labkit.ui.view.draw(ax, 'image', imageData, titleText); -end - -function imageSize = axesImageSize(ax) - imageSize = []; - images = findobj(ax, 'Type', 'Image'); - if isempty(images) - return; - end - data = images(1).CData; - imageSize = size(data); -end - -function zoomAxesAtPoint(ax, x, y, scrollCount, imageSize) - if scrollCount == 0 - return; - end - - fullX = [0.5, imageSize(2) + 0.5]; - fullY = [0.5, imageSize(1) + 0.5]; - zoomFactor = 1.20 ^ scrollCount; - - currentX = ax.XLim; - currentY = ax.YLim; - newWidth = diff(currentX) * zoomFactor; - newHeight = diff(currentY) * zoomFactor; - - minSpan = 10; - newWidth = min(max(newWidth, minSpan), diff(fullX)); - newHeight = min(max(newHeight, minSpan), diff(fullY)); - - xFrac = (x - currentX(1)) / max(eps, diff(currentX)); - yFrac = (y - currentY(1)) / max(eps, diff(currentY)); - xFrac = min(max(xFrac, 0), 1); - yFrac = min(max(yFrac, 0), 1); - - newX = [x - xFrac * newWidth, x + (1 - xFrac) * newWidth]; - newY = [y - yFrac * newHeight, y + (1 - yFrac) * newHeight]; - - ax.XLim = clampLimits(newX, fullX); - ax.YLim = clampLimits(newY, fullY); -end - -function limits = clampLimits(limits, fullLimits) - span = diff(limits); - fullSpan = diff(fullLimits); - if span >= fullSpan - limits = fullLimits; - return; - end - if limits(1) < fullLimits(1) - limits = [fullLimits(1), fullLimits(1) + span]; - end - if limits(2) > fullLimits(2) - limits = [fullLimits(2) - span, fullLimits(2)]; - end -end - -function overlay = makeFalseColorOverlay(referenceImage, alignedImage) - refGray = normalizeGray(referenceImage); - movGray = normalizeGray(alignedImage); - if ~isequal(size(refGray), size(movGray)) - movGray = imresize(movGray, size(refGray), 'nearest'); - end - overlay = zeros([size(refGray), 3]); - overlay(:, :, 1) = movGray; - overlay(:, :, 2) = refGray; -end - -function gray = normalizeGray(imageData) - if ndims(imageData) == 3 - gray = rgb2gray(imageData); - else - gray = imageData; - end - gray = im2double(gray); - values = gray(:); - values = values(~isnan(values)); - if isempty(values) - return; - end - mn = min(values); - mx = max(values); - if isfinite(mn) && isfinite(mx) && mx > mn - gray = (gray - mn) ./ (mx - mn); - end -end - -function lines = transformSummary(tform, referenceSize, movingSize) - T = transformMatrix(tform); - lines = { ... - sprintf('Reference size: %d x %d', referenceSize(1), referenceSize(2)), ... - sprintf('Moving size: %d x %d', movingSize(1), movingSize(2)), ... - 'Rigid transform matrix:', ... - sprintf('[%.6g %.6g %.6g]', T(1, 1), T(1, 2), T(1, 3)), ... - sprintf('[%.6g %.6g %.6g]', T(2, 1), T(2, 2), T(2, 3)), ... - sprintf('[%.6g %.6g %.6g]', T(3, 1), T(3, 2), T(3, 3))}; -end - -function T = transformMatrix(tform) - if isprop(tform, 'T') - T = tform.T; - elseif isprop(tform, 'A') - T = tform.A; - else - T = eye(3); - end -end - -function lines = cropSummary(rect) - lines = { ... - sprintf('Crop source: current reference and current moving images'), ... - sprintf('Crop rectangle: x=%g, y=%g, width=%g, height=%g', ... - rect(1), rect(2), rect(3), rect(4))}; -end - -function txt = displayPath(pathValue) - if strlength(pathValue) == 0 - txt = 'none'; - else - txt = char(pathValue); - end -end - -function txt = ternary(cond, trueText, falseText) - if cond - txt = trueText; - else - txt = falseText; - end -end - -function deleteIfValid(h) - if isempty(h) - return; - end - if isvalid(h) - delete(h); - end end diff --git a/apps/dic/private/alignMovingToReference.m b/apps/dic/private/alignMovingToReference.m new file mode 100644 index 0000000..deac04b --- /dev/null +++ b/apps/dic/private/alignMovingToReference.m @@ -0,0 +1,19 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function [alignedImage, tformRigid] = alignMovingToReference(referenceImage, movingImage, fixedPoints, movingPoints) + origClass = class(movingImage); + [~, ~, tr] = procrustes(fixedPoints, movingPoints, ... + 'Scaling', false, 'Reflection', false); + + R = tr.T; + t = tr.c(1, :); + T = [R(1,1) R(1,2) 0; ... + R(2,1) R(2,2) 0; ... + t(1) t(2) 1]; + tformRigid = affine2d(T); + + Rfixed = imref2d(size(referenceImage(:, :, 1))); + alignedImage = imwarp(movingImage, tformRigid, ... + 'OutputView', Rfixed, 'FillValues', 0); + alignedImage = cast(alignedImage, origClass); +end diff --git a/apps/dic/private/autoAlignMovingToReference.m b/apps/dic/private/autoAlignMovingToReference.m new file mode 100644 index 0000000..ad2f585 --- /dev/null +++ b/apps/dic/private/autoAlignMovingToReference.m @@ -0,0 +1,20 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function [alignedImage, tformRigid, method] = autoAlignMovingToReference(referenceImage, movingImage) + origClass = class(movingImage); + fixedGray = normalizeGray(referenceImage); + movingGray = normalizeGray(movingImage); + + try + tformRigid = imregcorr(movingGray, fixedGray, 'rigid'); + method = 'phase-correlation rigid registration'; + catch + tformRigid = imregcorr(movingGray, fixedGray, 'translation'); + method = 'phase-correlation translation registration'; + end + + Rfixed = imref2d(size(fixedGray)); + alignedImage = imwarp(movingImage, tformRigid, ... + 'OutputView', Rfixed, 'FillValues', 0); + alignedImage = cast(alignedImage, origClass); +end diff --git a/apps/dic/private/axesImageSize.m b/apps/dic/private/axesImageSize.m new file mode 100644 index 0000000..d62ca3c --- /dev/null +++ b/apps/dic/private/axesImageSize.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function imageSize = axesImageSize(ax) + imageSize = []; + images = findobj(ax, 'Type', 'Image'); + if isempty(images) + return; + end + data = images(1).CData; + imageSize = size(data); +end diff --git a/apps/dic/private/boundaryMaskImage.m b/apps/dic/private/boundaryMaskImage.m new file mode 100644 index 0000000..693e29f --- /dev/null +++ b/apps/dic/private/boundaryMaskImage.m @@ -0,0 +1,6 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function mask = boundaryMaskImage(points, imageSize, boundaryStyle) + curve = maskBoundaryCurve(points, imageSize, boundaryStyle); + mask = maskFromCurve(curve, imageSize); +end diff --git a/apps/dic/private/catmullRomPoint.m b/apps/dic/private/catmullRomPoint.m new file mode 100644 index 0000000..115872d --- /dev/null +++ b/apps/dic/private/catmullRomPoint.m @@ -0,0 +1,8 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function p = catmullRomPoint(p0, p1, p2, p3, t) + p = 0.5 .* ((2 .* p1) + ... + (-p0 + p2) .* t + ... + (2 .* p0 - 5 .* p1 + 4 .* p2 - p3) .* t.^2 + ... + (-p0 + 3 .* p1 - 3 .* p2 + p3) .* t.^3); +end diff --git a/apps/dic/private/chooseImageFile.m b/apps/dic/private/chooseImageFile.m new file mode 100644 index 0000000..86d96ed --- /dev/null +++ b/apps/dic/private/chooseImageFile.m @@ -0,0 +1,12 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function filepath = chooseImageFile(titleText) + [f, p] = uigetfile( ... + {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', 'Image files'; '*.*', 'All files'}, ... + titleText); + if isequal(f, 0) + filepath = ""; + else + filepath = string(fullfile(p, f)); + end +end diff --git a/apps/dic/private/clamp01.m b/apps/dic/private/clamp01.m new file mode 100644 index 0000000..07bd223 --- /dev/null +++ b/apps/dic/private/clamp01.m @@ -0,0 +1,5 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function x = clamp01(x) + x = min(max(x, 0), 1); +end diff --git a/apps/dic/private/clampLimits.m b/apps/dic/private/clampLimits.m new file mode 100644 index 0000000..1c88b6a --- /dev/null +++ b/apps/dic/private/clampLimits.m @@ -0,0 +1,16 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function limits = clampLimits(limits, fullLimits) + span = diff(limits); + fullSpan = diff(fullLimits); + if span >= fullSpan + limits = fullLimits; + return; + end + if limits(1) < fullLimits(1) + limits = [fullLimits(1), fullLimits(1) + span]; + end + if limits(2) > fullLimits(2) + limits = [fullLimits(2) - span, fullLimits(2)]; + end +end diff --git a/apps/dic/private/colorbarLevelsTable.m b/apps/dic/private/colorbarLevelsTable.m new file mode 100644 index 0000000..49df5e3 --- /dev/null +++ b/apps/dic/private/colorbarLevelsTable.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function T = colorbarLevelsTable(opts) + n = size(opts.colormap, 1); + strainLevel = linspace(opts.colorRange(1), opts.colorRange(2), n).'; + red = opts.colormap(:, 1); + green = opts.colormap(:, 2); + blue = opts.colormap(:, 3); + T = table(strainLevel, red, green, blue, ... + 'VariableNames', {'StrainLevel', 'Red', 'Green', 'Blue'}); +end diff --git a/apps/dic/private/cropSelectionSummary.m b/apps/dic/private/cropSelectionSummary.m new file mode 100644 index 0000000..64dc9f3 --- /dev/null +++ b/apps/dic/private/cropSelectionSummary.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function lines = cropSelectionSummary(rect) + lines = { ... + sprintf('Active crop source: current reference and current moving images'), ... + sprintf('Move or resize the ROI on the current reference preview, then click Apply ROI crop.'), ... + sprintf('Current square ROI: x=%d, y=%d, size=%d px', ... + round(rect(1)), round(rect(2)), round(rect(3)))}; +end diff --git a/apps/dic/private/cropSummary.m b/apps/dic/private/cropSummary.m new file mode 100644 index 0000000..afac374 --- /dev/null +++ b/apps/dic/private/cropSummary.m @@ -0,0 +1,8 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function lines = cropSummary(rect) + lines = { ... + sprintf('Crop source: current reference and current moving images'), ... + sprintf('Crop rectangle: x=%g, y=%g, width=%g, height=%g', ... + rect(1), rect(2), rect(3), rect(4))}; +end diff --git a/apps/dic/private/defaultSquareRect.m b/apps/dic/private/defaultSquareRect.m new file mode 100644 index 0000000..9fe25a6 --- /dev/null +++ b/apps/dic/private/defaultSquareRect.m @@ -0,0 +1,10 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function rect = defaultSquareRect(imageSize) + H = imageSize(1); + W = imageSize(2); + side = max(1, round(0.5 * min(H, W))); + x = round((W - side) / 2) + 1; + y = round((H - side) / 2) + 1; + rect = squareRectInsideImage([x y side side], imageSize); +end diff --git a/apps/dic/private/deleteIfValid.m b/apps/dic/private/deleteIfValid.m new file mode 100644 index 0000000..ac259c3 --- /dev/null +++ b/apps/dic/private/deleteIfValid.m @@ -0,0 +1,10 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function deleteIfValid(h) + if isempty(h) + return; + end + if isvalid(h) + delete(h); + end +end diff --git a/apps/dic/private/displayPath.m b/apps/dic/private/displayPath.m new file mode 100644 index 0000000..30a59a1 --- /dev/null +++ b/apps/dic/private/displayPath.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function txt = displayPath(pathValue) + if strlength(pathValue) == 0 + txt = 'none'; + else + txt = char(pathValue); + end +end diff --git a/apps/dic/private/enhanceReferenceImage.m b/apps/dic/private/enhanceReferenceImage.m new file mode 100644 index 0000000..58ad5c0 --- /dev/null +++ b/apps/dic/private/enhanceReferenceImage.m @@ -0,0 +1,17 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function img = enhanceReferenceImage(referenceImage, opts) + img = ensureRgb(im2double(referenceImage)); + gains = reshape(opts.rgbGain, 1, 1, 3); + img = img .* gains; + img = clamp01(img); + + hsvImage = rgb2hsv(img); + hsvImage(:, :, 2) = clamp01(hsvImage(:, :, 2) .* opts.saturation); + img = hsv2rgb(hsvImage); + + img = (img - 0.5) .* opts.contrast + 0.5 + opts.brightness; + img = clamp01(img); + img = img .^ opts.gamma; + img = clamp01(img); +end diff --git a/apps/dic/private/ensureRgb.m b/apps/dic/private/ensureRgb.m new file mode 100644 index 0000000..f720300 --- /dev/null +++ b/apps/dic/private/ensureRgb.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function out = ensureRgb(imageData) + if ndims(imageData) == 2 + out = repmat(imageData, [1 1 3]); + else + out = imageData; + end +end diff --git a/apps/dic/private/exportOverlayFigure.m b/apps/dic/private/exportOverlayFigure.m new file mode 100644 index 0000000..7653ece --- /dev/null +++ b/apps/dic/private/exportOverlayFigure.m @@ -0,0 +1,13 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function exportOverlayFigure(overlayImage, componentName, colorRange, resolution, outfile) + fig = figure('Visible', 'off'); + cleanup = onCleanup(@() close(fig)); + imshow(overlayImage); + title(sprintf('Strain %s', componentName)); + colormap(jet); + clim(colorRange); + cb = colorbar; + cb.Label.String = sprintf('Strain %s', componentName); + exportgraphics(fig, outfile, 'Resolution', resolution); +end diff --git a/apps/dic/private/exportStrainColorbar.m b/apps/dic/private/exportStrainColorbar.m new file mode 100644 index 0000000..73220dc --- /dev/null +++ b/apps/dic/private/exportStrainColorbar.m @@ -0,0 +1,16 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function exportStrainColorbar(opts, outfile) + fig = figure('Visible', 'off', 'Position', [100 100 420 720]); + cleanup = onCleanup(@() close(fig)); + ax = axes(fig, 'Position', [0.18 0.08 0.24 0.86]); + levels = linspace(opts.colorRange(1), opts.colorRange(2), size(opts.colormap, 1)); + imagesc(ax, 1, levels, levels(:)); + set(ax, 'XTick', [], 'YDir', 'normal'); + ylabel(ax, 'Strain level'); + colormap(ax, opts.colormap); + clim(ax, opts.colorRange); + cb = colorbar(ax, 'Location', 'eastoutside'); + cb.Label.String = 'Strain level'; + exportgraphics(fig, outfile, 'Resolution', opts.exportResolution); +end diff --git a/apps/dic/private/extendStrainMapToRoi.m b/apps/dic/private/extendStrainMapToRoi.m new file mode 100644 index 0000000..1837734 --- /dev/null +++ b/apps/dic/private/extendStrainMapToRoi.m @@ -0,0 +1,14 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function Sfilled = extendStrainMapToRoi(S, validMap) + validMap = logical(validMap) & isfinite(S); + Sfilled = S; + if ~any(validMap(:)) + Sfilled(:) = NaN; + return; + end + + [~, nearestIdx] = bwdist(validMap); + invalid = ~validMap; + Sfilled(invalid) = S(nearestIdx(invalid)); +end diff --git a/apps/dic/private/imageHeightWidth.m b/apps/dic/private/imageHeightWidth.m new file mode 100644 index 0000000..4da0939 --- /dev/null +++ b/apps/dic/private/imageHeightWidth.m @@ -0,0 +1,5 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function targetSize = imageHeightWidth(imageData) + targetSize = [size(imageData, 1), size(imageData, 2)]; +end diff --git a/apps/dic/private/imageMask.m b/apps/dic/private/imageMask.m new file mode 100644 index 0000000..c7b8bf0 --- /dev/null +++ b/apps/dic/private/imageMask.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function mask = imageMask(maskImage, targetSize) + if ndims(maskImage) == 3 + maskImage = rgb2gray(maskImage); + end + mask = maskImage > 128; + mask = imresize(mask, targetSize, 'nearest'); +end diff --git a/apps/dic/private/insideImageBounds.m b/apps/dic/private/insideImageBounds.m new file mode 100644 index 0000000..bc3aaa5 --- /dev/null +++ b/apps/dic/private/insideImageBounds.m @@ -0,0 +1,7 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function tf = insideImageBounds(x, y, imageSize) + tf = isfinite(x) && isfinite(y) && ... + x >= 0.5 && y >= 0.5 && ... + x <= imageSize(2) + 0.5 && y <= imageSize(1) + 0.5; +end diff --git a/apps/dic/private/loadNcorrStrain.m b/apps/dic/private/loadNcorrStrain.m new file mode 100644 index 0000000..7cfc945 --- /dev/null +++ b/apps/dic/private/loadNcorrStrain.m @@ -0,0 +1,25 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function strain = loadNcorrStrain(matFile) + data = load(matFile, 'data_dic_save'); + if ~isfield(data, 'data_dic_save') || ~isfield(data.data_dic_save, 'strains') + error('MAT file must contain data_dic_save.strains.'); + end + + strains = data.data_dic_save.strains; + required = {'plot_exx_ref_formatted', 'plot_eyy_ref_formatted'}; + for i = 1:numel(required) + if ~isfield(strains, required{i}) + error('Missing data_dic_save.strains.%s.', required{i}); + end + end + + strain = struct(); + strain.exx = strains.plot_exx_ref_formatted; + strain.eyy = strains.plot_eyy_ref_formatted; + strain.roiMask = []; + if isfield(strains, 'roi_ref_formatted') && ... + isfield(strains.roi_ref_formatted, 'mask') + strain.roiMask = logical(strains.roi_ref_formatted.mask); + end +end diff --git a/apps/dic/private/makeFalseColorOverlay.m b/apps/dic/private/makeFalseColorOverlay.m new file mode 100644 index 0000000..a5d737d --- /dev/null +++ b/apps/dic/private/makeFalseColorOverlay.m @@ -0,0 +1,12 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function overlay = makeFalseColorOverlay(referenceImage, alignedImage) + refGray = normalizeGray(referenceImage); + movGray = normalizeGray(alignedImage); + if ~isequal(size(refGray), size(movGray)) + movGray = imresize(movGray, size(refGray), 'nearest'); + end + overlay = zeros([size(refGray), 3]); + overlay(:, :, 1) = movGray; + overlay(:, :, 2) = refGray; +end diff --git a/apps/dic/private/makeStrainOverlay.m b/apps/dic/private/makeStrainOverlay.m new file mode 100644 index 0000000..7e262f4 --- /dev/null +++ b/apps/dic/private/makeStrainOverlay.m @@ -0,0 +1,13 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function overlay = makeStrainOverlay(referenceImage, strainMap, mask, roiMask, opts) + orig = enhanceReferenceImage(referenceImage, opts); + [H, W, ~] = size(orig); + mask = imresize(logical(mask), [H W], 'nearest'); + validMap = strainValidMask(strainMap, roiMask, mask); + [strainRgb, validStrain] = strainToRgb(strainMap, validMap, [H W], opts); + overlayMask = mask & validStrain; + mask3 = repmat(overlayMask, [1 1 3]); + overlay = orig; + overlay(mask3) = (1 - opts.alpha) .* orig(mask3) + opts.alpha .* strainRgb(mask3); +end diff --git a/apps/dic/private/maskBoundaryCurve.m b/apps/dic/private/maskBoundaryCurve.m new file mode 100644 index 0000000..6ad82a7 --- /dev/null +++ b/apps/dic/private/maskBoundaryCurve.m @@ -0,0 +1,34 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function curve = maskBoundaryCurve(points, imageSize, boundaryStyle) + if size(points, 1) < 3 + curve = []; + return; + end + if strcmp(string(boundaryStyle), "Straight lines") + curve = [points; points(1, :)]; + curve(:, 1) = min(max(curve(:, 1), 0.5), imageSize(2) + 0.5); + curve(:, 2) = min(max(curve(:, 2), 0.5), imageSize(1) + 0.5); + return; + end + + n = size(points, 1); + samplesPerSegment = max(12, ceil(240 / n)); + curve = zeros(n * samplesPerSegment + 1, 2); + out = 1; + for i = 1:n + p0 = points(wrapIndex(i - 1, n), :); + p1 = points(i, :); + p2 = points(wrapIndex(i + 1, n), :); + p3 = points(wrapIndex(i + 2, n), :); + for k = 0:(samplesPerSegment - 1) + t = k / samplesPerSegment; + curve(out, :) = catmullRomPoint(p0, p1, p2, p3, t); + out = out + 1; + end + end + curve(out, :) = curve(1, :); + curve = curve(1:out, :); + curve(:, 1) = min(max(curve(:, 1), 0.5), imageSize(2) + 0.5); + curve(:, 2) = min(max(curve(:, 2), 0.5), imageSize(1) + 0.5); +end diff --git a/apps/dic/private/maskFromCurve.m b/apps/dic/private/maskFromCurve.m new file mode 100644 index 0000000..47941e0 --- /dev/null +++ b/apps/dic/private/maskFromCurve.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function mask = maskFromCurve(curve, imageSize) + H = imageSize(1); + W = imageSize(2); + if isempty(curve) + mask = uint8(false(H, W)); + return; + end + mask = uint8(poly2mask(curve(:, 1), curve(:, 2), H, W)) .* uint8(255); +end diff --git a/apps/dic/private/maskRgb.m b/apps/dic/private/maskRgb.m new file mode 100644 index 0000000..e97f6d7 --- /dev/null +++ b/apps/dic/private/maskRgb.m @@ -0,0 +1,5 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function rgb = maskRgb(maskImage) + rgb = repmat(maskImage, [1 1 3]); +end diff --git a/apps/dic/private/nanSafeStats.m b/apps/dic/private/nanSafeStats.m new file mode 100644 index 0000000..711d02e --- /dev/null +++ b/apps/dic/private/nanSafeStats.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function values = nanSafeStats(x) + x = x(:); + x = x(isfinite(x)); + if isempty(x) + values = nan(5, 1); + return; + end + values = [mean(x); std(x); median(x); min(x); max(x)]; +end diff --git a/apps/dic/private/normalizeGray.m b/apps/dic/private/normalizeGray.m new file mode 100644 index 0000000..30d4f6c --- /dev/null +++ b/apps/dic/private/normalizeGray.m @@ -0,0 +1,20 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function gray = normalizeGray(imageData) + if ndims(imageData) == 3 + gray = rgb2gray(imageData); + else + gray = imageData; + end + gray = im2double(gray); + values = gray(:); + values = values(~isnan(values)); + if isempty(values) + return; + end + mn = min(values); + mx = max(values); + if isfinite(mn) && isfinite(mx) && mx > mn + gray = (gray - mn) ./ (mx - mn); + end +end diff --git a/apps/dic/private/runDICPreprocessApp.m b/apps/dic/private/runDICPreprocessApp.m new file mode 100644 index 0000000..8300077 --- /dev/null +++ b/apps/dic/private/runDICPreprocessApp.m @@ -0,0 +1,908 @@ +% App-owned DIC preprocess runner. Expected caller: labkit_DICPreprocess_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, and debug trace +% attachment exactly as in the original entrypoint body. +function fig = runDICPreprocessApp(debugLog) +%RUNDICPREPROCESSAPP Build and run the DIC preprocess app body. + + S = struct(); + S.referencePath = ""; + S.movingPath = ""; + S.referenceImage = []; + S.movingImage = []; + S.currentReferenceImage = []; + S.currentMovingImage = []; + S.alignedImage = []; + S.cropReference = []; + S.cropMoving = []; + S.cropRect = []; + S.cropRoiTop = []; + S.cropRoiBottom = []; + S.cropRoiListeners = {}; + S.maskImage = []; + S.maskPoints = []; + S.maskEditor = []; + S.maskBoundaryStyle = "Curve"; + S.maskEditActive = false; + S.maskHistory = struct('maskImage', {}, 'maskPoints', {}, 'description', {}); + S.history = struct('reference', {}, 'moving', {}, 'aligned', {}, ... + 'cropReference', {}, 'cropMoving', {}, 'maskImage', {}, ... + 'maskPoints', {}, 'description', {}); + + workbenchOpts = struct('rightKind', 'dualPlot', ... + 'rightTitle', 'Image Preview', ... + 'topPlotTitle', 'Reference', ... + 'bottomPlotTitle', 'Current Preview', ... + 'showPlotControls', false); + workbenchOpts.tabs = [ ... + labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [4 1], ... + {240, 210, 330, 170}, ... + struct('resizeRows', [1 2 3], ... + 'resizeOptions', struct('minTopHeight', 120, 'minBottomHeight', 80))), ... + labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... + {150, '1x'}, ... + struct('resizeRows', 1, ... + 'resizeOptions', struct('minTopHeight', 90, 'minBottomHeight', 90))), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; + ui = labkit.ui.app.createShell(struct( ... + 'title', 'DIC Image Preprocess', ... + 'position', [80 60 1400 860], ... + 'leftWidth', 370, ... + 'options', workbenchOpts)); + fig = ui.fig; + imageRuntime = labkit.ui.tool.createRuntime(ui.topAxes, ... + struct('figure', fig, 'defaultScrollFcn', @onPreviewScrollZoom)); + + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + filePanel = labkit.ui.view.section(layFA, 'Images', 1, [4 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + fileGrid = filePanel.grid; + + btnReference = uibutton(fileGrid, 'Text', 'Open reference image', ... + 'ButtonPushedFcn', @onOpenReference); + btnReference.Layout.Row = 1; + btnReference.Layout.Column = 1; + btnMoving = uibutton(fileGrid, 'Text', 'Open moving image', ... + 'ButtonPushedFcn', @onOpenMoving); + btnMoving.Layout.Row = 1; + btnMoving.Layout.Column = 2; + + txtReference = labkit.ui.view.form(fileGrid, 'readonly', ... + 'Value', 'No reference image loaded'); + txtReference.Layout.Row = 2; + txtReference.Layout.Column = [1 2]; + txtMoving = labkit.ui.view.form(fileGrid, 'readonly', ... + 'Value', 'No moving image loaded'); + txtMoving.Layout.Row = 3; + txtMoving.Layout.Column = [1 2]; + + [lblPreview, ddPreview] = labkit.ui.view.form(fileGrid, 'dropdown', 'Preview:', ... + 'Items', {'Current pair', 'Current moving image', 'False-color overlay', 'Original pair', 'ROI mask'}, ... + 'Value', 'Current pair', ... + 'ValueChangedFcn', @(~,~) refreshPreview()); + lblPreview.Layout.Row = 4; + lblPreview.Layout.Column = 1; + ddPreview.Layout.Row = 4; + ddPreview.Layout.Column = 2; + + actionPanel = labkit.ui.view.section(layFA, 'Registration + Crop', 2, [6 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + actionGrid = actionPanel.grid; + + btnAlign = uibutton(actionGrid, 'Text', 'Select points + align', ... + 'ButtonPushedFcn', @onAlign); + btnAlign.Layout.Row = 1; + btnAlign.Layout.Column = [1 2]; + btnAutoAlign = uibutton(actionGrid, 'Text', 'Auto align current pair', ... + 'ButtonPushedFcn', @onAutoAlign); + btnAutoAlign.Layout.Row = 2; + btnAutoAlign.Layout.Column = [1 2]; + btnCrop = uibutton(actionGrid, 'Text', 'Start/reset crop ROI', ... + 'ButtonPushedFcn', @onStartCropRoi); + btnCrop.Layout.Row = 3; + btnCrop.Layout.Column = [1 2]; + btnApplyCrop = uibutton(actionGrid, 'Text', 'Apply ROI crop', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onApplyCropRoi); + btnApplyCrop.Layout.Row = 4; + btnApplyCrop.Layout.Column = 1; + btnCancelCrop = uibutton(actionGrid, 'Text', 'Cancel ROI', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onCancelCropRoi); + btnCancelCrop.Layout.Row = 4; + btnCancelCrop.Layout.Column = 2; + btnUndoEdit = uibutton(actionGrid, 'Text', 'Undo align/crop', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onUndoEdit); + btnUndoEdit.Layout.Row = 5; + btnUndoEdit.Layout.Column = 1; + btnSaveCurrent = uibutton(actionGrid, 'Text', 'Save current images', ... + 'ButtonPushedFcn', @onSaveCurrentImages); + btnSaveCurrent.Layout.Row = 5; + btnSaveCurrent.Layout.Column = 2; + btnResetCurrent = uibutton(actionGrid, 'Text', 'Reset to originals', ... + 'ButtonPushedFcn', @onResetToOriginals); + btnResetCurrent.Layout.Row = 6; + btnResetCurrent.Layout.Column = [1 2]; + maskPanel = labkit.ui.view.section(layFA, 'Mask ROI', 3, [7 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{'1x', '1x'}})); + maskGrid = maskPanel.grid; + + btnStartMask = uibutton(maskGrid, 'Text', 'Start ROI edit', ... + 'ButtonPushedFcn', @onStartMaskEdit); + btnStartMask.Layout.Row = 1; + btnStartMask.Layout.Column = [1 2]; + [lblBoundaryStyle, ddBoundaryStyle] = labkit.ui.view.form(maskGrid, 'dropdown', 'Boundary:', ... + 'Items', {'Curve', 'Straight lines'}, ... + 'Value', 'Curve', ... + 'ValueChangedFcn', @onBoundaryStyleChanged); + lblBoundaryStyle.Layout.Row = 2; + lblBoundaryStyle.Layout.Column = 1; + ddBoundaryStyle.Layout.Row = 2; + ddBoundaryStyle.Layout.Column = 2; + ddBoundaryStyle.Enable = 'off'; + btnPreviewMask = uibutton(maskGrid, 'Text', 'Preview ROI mask', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onPreviewMaskRoi); + btnPreviewMask.Layout.Row = 3; + btnPreviewMask.Layout.Column = 1; + btnUnionMask = uibutton(maskGrid, 'Text', 'Add to mask', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onAddBoundaryToMask); + btnUnionMask.Layout.Row = 3; + btnUnionMask.Layout.Column = 2; + btnSubtractMask = uibutton(maskGrid, 'Text', 'Subtract from mask', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onSubtractBoundaryFromMask); + btnSubtractMask.Layout.Row = 4; + btnSubtractMask.Layout.Column = 1; + btnUndoMask = uibutton(maskGrid, 'Text', 'Undo point', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onUndoMaskAnchor); + btnUndoMask.Layout.Row = 4; + btnUndoMask.Layout.Column = 2; + btnUndoMaskEdit = uibutton(maskGrid, 'Text', 'Undo mask edit', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onUndoMaskEdit); + btnUndoMaskEdit.Layout.Row = 5; + btnUndoMaskEdit.Layout.Column = 1; + btnClearBoundary = uibutton(maskGrid, 'Text', 'Clear boundary', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onClearMaskBoundary); + btnClearBoundary.Layout.Row = 5; + btnClearBoundary.Layout.Column = 2; + btnClearMask = uibutton(maskGrid, 'Text', 'Clear mask', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', @onClearMaskCanvas); + btnClearMask.Layout.Row = 6; + btnClearMask.Layout.Column = [1 2]; + btnSaveMask = uibutton(maskGrid, 'Text', 'Save ROI mask', ... + 'ButtonPushedFcn', @onSaveMask); + btnSaveMask.Layout.Row = 7; + btnSaveMask.Layout.Column = [1 2]; + + labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 4, { ... + '1. Load a reference image and a moving image.', ... + '2. Align or crop the current working pair in any order; each apply step can be undone.', ... + '3. False-color preview compares the current pair even before alignment.', ... + '4. Draw curve or straight-line ROI boundaries, add/subtract them on the mask canvas, then save the mask.'}); + + txtSummary = uitextarea(laySR, 'Editable', 'off'); + txtSummary.Layout.Row = 1; + txtSummary.Value = {'No images loaded.'}; + + txtDetails = uitextarea(laySR, 'Editable', 'off'); + labkit.ui.view.place(txtDetails, laySR, 2); + txtDetails.Value = {'Alignment and crop details will appear here.'}; + + logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); + txtLog = logUi.textArea; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('DIC preprocess debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetPreviewAxes(); + + + function onOpenReference(~, ~) + filepath = chooseImageFile('Select reference image'); + if filepath == "" + addLog('Reference image selection cancelled.'); + return; + end + S.referencePath = filepath; + S.referenceImage = imread(filepath); + S.currentReferenceImage = S.referenceImage; + resetWorkflowStateForNewInput(); + txtReference.Value = char(filepath); + addLog(sprintf('Loaded reference image: %s', filepath)); + chooseDefaultPreviewAfterLoad(); + refreshPreview(); + end + + function onOpenMoving(~, ~) + filepath = chooseImageFile('Select moving image'); + if filepath == "" + addLog('Moving image selection cancelled.'); + return; + end + S.movingPath = filepath; + S.movingImage = imread(filepath); + S.currentMovingImage = S.movingImage; + resetWorkflowStateForNewInput(); + txtMoving.Value = char(filepath); + addLog(sprintf('Loaded moving image: %s', filepath)); + chooseDefaultPreviewAfterLoad(); + refreshPreview(); + end + + function onAlign(~, ~) + if ~hasImagePair() + uialert(fig, 'Load both reference and moving images before alignment.', 'Missing images'); + return; + end + + addLog('Opening point selector. Choose matching points, then accept.'); + [movingPoints, fixedPoints] = cpselect(S.currentMovingImage, S.currentReferenceImage, 'Wait', true); + if size(movingPoints, 1) < 2 + uialert(fig, 'Rigid registration requires at least two point pairs.', 'Not enough points'); + addLog('Alignment cancelled: fewer than two point pairs.'); + return; + end + + pushHistory('manual alignment'); + [alignedImage, tform] = alignMovingToReference( ... + S.currentReferenceImage, S.currentMovingImage, fixedPoints, movingPoints); + S.currentMovingImage = alignedImage; + S.alignedImage = alignedImage; + clearOperationDerivedState(); + ddPreview.Value = 'False-color overlay'; + addLog(sprintf('Aligned image using %d point pair(s).', size(movingPoints, 1))); + txtDetails.Value = transformSummary(tform, size(S.currentReferenceImage), size(S.currentMovingImage)); + refreshPreview(); + end + + function onAutoAlign(~, ~) + if ~hasImagePair() + uialert(fig, 'Load both reference and moving images before automatic alignment.', 'Missing images'); + return; + end + + try + [alignedImage, tform, method] = autoAlignMovingToReference( ... + S.currentReferenceImage, S.currentMovingImage); + catch err + uialert(fig, sprintf('Automatic alignment failed:\n%s', err.message), 'Auto align failed'); + addLog(sprintf('Automatic alignment failed: %s', err.message)); + return; + end + + pushHistory('automatic alignment'); + S.currentMovingImage = alignedImage; + S.alignedImage = alignedImage; + clearOperationDerivedState(); + ddPreview.Value = 'False-color overlay'; + addLog(sprintf('Automatically aligned current pair using %s.', method)); + txtDetails.Value = transformSummary(tform, size(S.currentReferenceImage), size(S.currentMovingImage)); + refreshPreview(); + end + + function onStartCropRoi(~, ~) + if ~hasImagePair() + uialert(fig, 'Load both reference and moving images before cropping.', 'Missing images'); + return; + end + + clearCropRoi(); + clearMaskRoi(); + resetPreviewAxes(); + showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); + showImage(ui.bottomAxes, S.currentMovingImage, 'Current moving'); + + S.cropReference = []; + S.cropMoving = []; + rect = defaultSquareRect(size(S.currentReferenceImage)); + S.cropRect = rect; + S.cropRoiTop = drawrectangle(ui.topAxes, ... + 'Position', rect, ... + 'FixedAspectRatio', true, ... + 'Color', [1 0.85 0], ... + 'LineWidth', 1.5); + S.cropRoiBottom = rectangle(ui.bottomAxes, ... + 'Position', rect, ... + 'EdgeColor', [1 0.85 0], ... + 'LineWidth', 1.5, ... + 'LineStyle', '--'); + S.cropRoiListeners = { ... + addlistener(S.cropRoiTop, 'MovingROI', @onCropRoiMoved), ... + addlistener(S.cropRoiTop, 'ROIMoved', @onCropRoiMoved)}; + btnApplyCrop.Enable = 'on'; + btnCancelCrop.Enable = 'on'; + txtDetails.Value = cropSelectionSummary(rect); + addLog('Started crop ROI on the current pair preview.'); + refreshSummary(); + end + + function onApplyCropRoi(~, ~) + if isempty(S.cropRoiTop) || ~isvalid(S.cropRoiTop) + uialert(fig, 'Start a crop ROI before applying the crop.', 'No active ROI'); + return; + end + + rect = squareRectInsideImage(S.cropRoiTop.Position, size(S.currentReferenceImage)); + pushHistory('crop'); + S.cropRect = rect; + S.currentReferenceImage = imcrop(S.currentReferenceImage, rect); + S.currentMovingImage = imcrop(S.currentMovingImage, rect); + S.cropReference = S.currentReferenceImage; + S.cropMoving = S.currentMovingImage; + clearOperationDerivedState(); + clearCropRoi(); + ddPreview.Value = 'Current pair'; + showCurrentPair(); + addLog(sprintf('Cropped current pair with [%g %g %g %g].', ... + rect(1), rect(2), rect(3), rect(4))); + txtDetails.Value = cropSummary(rect); + refreshSummary(); + end + + function onCancelCropRoi(~, ~) + clearCropRoi(); + addLog('Crop ROI cancelled.'); + refreshPreview(); + end + + function onCropRoiMoved(~, evt) + if isprop(evt, 'CurrentPosition') + pos = evt.CurrentPosition; + else + pos = S.cropRoiTop.Position; + end + rect = squareRectInsideImage(pos, size(S.currentReferenceImage)); + S.cropRect = rect; + if ~isempty(S.cropRoiBottom) && isvalid(S.cropRoiBottom) + S.cropRoiBottom.Position = rect; + end + txtDetails.Value = cropSelectionSummary(rect); + end + + function onUndoEdit(~, ~) + if isempty(S.history) + uialert(fig, 'No align or crop operation is available to undo.', 'Undo'); + return; + end + + snapshot = S.history(end); + S.history(end) = []; + clearCropRoi(); + clearMaskRoi(); + S.currentReferenceImage = snapshot.reference; + S.currentMovingImage = snapshot.moving; + S.alignedImage = snapshot.aligned; + S.cropReference = snapshot.cropReference; + S.cropMoving = snapshot.cropMoving; + S.maskImage = snapshot.maskImage; + S.maskPoints = snapshot.maskPoints; + ddPreview.Value = 'Current pair'; + addLog(sprintf('Undid %s.', snapshot.description)); + txtDetails.Value = {sprintf('Restored state before %s.', snapshot.description)}; + refreshPreview(); + updateUndoButton(); + end + + function onResetToOriginals(~, ~) + if isempty(S.referenceImage) || isempty(S.movingImage) + uialert(fig, 'Load both images before resetting the working pair.', 'Reset'); + return; + end + pushHistory('reset to originals'); + S.currentReferenceImage = S.referenceImage; + S.currentMovingImage = S.movingImage; + S.alignedImage = []; + S.cropReference = []; + S.cropMoving = []; + clearCropRoi(); + clearMaskRoi(); + clearOperationDerivedState(); + ddPreview.Value = 'Current pair'; + addLog('Reset current working pair to the original loaded images.'); + txtDetails.Value = {'Current working pair reset to originals.'}; + refreshPreview(); + end + + function onSaveCurrentImages(~, ~) + if ~hasImagePair() + uialert(fig, 'Load both images before saving the current pair.', 'Save current images'); + return; + end + + folder = uigetdir(defaultSaveFolder(), 'Select folder for current images'); + if isequal(folder, 0) + addLog('Save current images cancelled.'); + return; + end + + refOut = fullfile(folder, 'current_reference.png'); + curOut = fullfile(folder, 'current_moving.png'); + imwrite(S.currentReferenceImage, refOut); + imwrite(S.currentMovingImage, curOut); + addLog(sprintf('Saved current images: %s and %s', refOut, curOut)); + end + + function onStartMaskEdit(~, ~) + if isempty(S.currentReferenceImage) + uialert(fig, 'Load a reference image before drawing an ROI mask.', 'Missing image'); + return; + end + + clearCropRoi(); + clearMaskRoi(); + resetPreviewAxes(); + hTopImage = showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); + showImage(ui.bottomAxes, zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 3, 'uint8'), 'ROI mask preview'); + S.maskImage = []; + S.maskPoints = []; + S.maskHistory = S.maskHistory([]); + S.maskBoundaryStyle = string(ddBoundaryStyle.Value); + S.maskEditor = labkit.ui.tool.anchorEditor(imageRuntime, size(S.currentReferenceImage), ... + struct('closed', true, ... + 'style', S.maskBoundaryStyle, ... + 'installScrollWheel', false, ... + 'onChanged', @onMaskEditorChanged)); + S.maskEditor.setBackground(hTopImage); + S.maskEditor.start(S.maskPoints); + setMaskEditControls(true); + addLog('Started mask ROI canvas. Add/insert, move, or delete anchors; add/subtract boundaries on the mask canvas.'); + txtDetails.Value = {'ROI edit started. Double-click blank space to add/insert points, drag points to move them, double-click points to delete them.'}; + updateMaskEditControls(); + end + + function onMaskEditorChanged(points, ~) + S.maskPoints = points; + updateMaskDraft(); + end + + function onBoundaryStyleChanged(~, ~) + S.maskBoundaryStyle = string(ddBoundaryStyle.Value); + if ~isempty(S.maskEditor) + S.maskEditor.setStyle(S.maskBoundaryStyle); + end + updateMaskCurveGraphics(); + txtDetails.Value = {sprintf('Boundary style: %s.', char(S.maskBoundaryStyle))}; + end + + function onUndoMaskAnchor(~, ~) + if ~isempty(S.maskEditor) + S.maskEditor.undoLast(); + end + end + + function onClearMaskBoundary(~, ~) + if ~isempty(S.maskEditor) + S.maskEditor.clearPoints(); + else + S.maskPoints = []; + updateMaskDraft(); + end + addLog('Cleared mask ROI boundary anchors.'); + end + + function onClearMaskCanvas(~, ~) + if isempty(S.maskImage) + return; + end + pushMaskHistory('clear mask canvas'); + S.maskImage = []; + showMaskCanvas('ROI mask canvas'); + addLog('Cleared ROI mask canvas.'); + refreshSummary(); + end + + function updateMaskDraft() + updateMaskCurveGraphics(); + updateMaskEditControls(); + if size(S.maskPoints, 1) >= 3 + txtDetails.Value = {sprintf('Mask ROI anchors: %d. Preview, Add to mask, or Subtract from mask.', size(S.maskPoints, 1))}; + else + txtDetails.Value = {sprintf('Mask ROI anchors: %d. Need at least 3 anchors to form a closed ROI boundary.', size(S.maskPoints, 1))}; + end + refreshSummary(); + end + + function updateMaskCurveGraphics() + if ~isempty(S.maskEditor) + S.maskEditor.refresh(); + end + end + + function setMaskEditControls(enabled) + S.maskEditActive = enabled; + state = ternary(enabled, 'on', 'off'); + ddBoundaryStyle.Enable = state; + updateMaskEditControls(); + end + + function updateMaskEditControls() + editActive = S.maskEditActive; + hasPoints = ~isempty(S.maskPoints); + canBoundary = size(S.maskPoints, 1) >= 3; + canUndoCanvas = ~isempty(S.maskHistory); + canClearCanvas = ~isempty(S.maskImage); + btnPreviewMask.Enable = ternary(editActive && (canBoundary || canClearCanvas), 'on', 'off'); + btnUnionMask.Enable = ternary(editActive && canBoundary, 'on', 'off'); + btnSubtractMask.Enable = ternary(editActive && canBoundary, 'on', 'off'); + btnUndoMask.Enable = ternary(editActive && hasPoints, 'on', 'off'); + btnClearBoundary.Enable = ternary(editActive && hasPoints, 'on', 'off'); + btnUndoMaskEdit.Enable = ternary(editActive && canUndoCanvas, 'on', 'off'); + btnClearMask.Enable = ternary(editActive && canClearCanvas, 'on', 'off'); + end + + function onPreviewMaskRoi(~, ~) + previewMaskImage(true); + end + + function onAddBoundaryToMask(~, ~) + [boundaryMask, ok] = currentBoundaryMask(true); + if ~ok + return; + end + pushMaskHistory('add boundary to mask'); + S.maskImage = max(maskCanvas(), boundaryMask); + showMaskCanvas('ROI mask canvas'); + addLog(sprintf('Added %s boundary to ROI mask canvas.', char(S.maskBoundaryStyle))); + refreshSummary(); + end + + function onSubtractBoundaryFromMask(~, ~) + [boundaryMask, ok] = currentBoundaryMask(true); + if ~ok + return; + end + pushMaskHistory('subtract boundary from mask'); + canvas = maskCanvas(); + canvas(boundaryMask > 0) = 0; + S.maskImage = canvas; + showMaskCanvas('ROI mask canvas'); + addLog(sprintf('Subtracted %s boundary from ROI mask canvas.', char(S.maskBoundaryStyle))); + refreshSummary(); + end + + function onUndoMaskEdit(~, ~) + if isempty(S.maskHistory) + return; + end + snapshot = S.maskHistory(end); + S.maskHistory(end) = []; + S.maskImage = snapshot.maskImage; + S.maskPoints = snapshot.maskPoints; + if ~isempty(S.maskEditor) + S.maskEditor.setPoints(S.maskPoints); + end + updateMaskCurveGraphics(); + showMaskCanvas('ROI mask canvas'); + addLog(sprintf('Undid mask edit: %s.', snapshot.description)); + refreshSummary(); + end + + function onPreviewScrollZoom(~, evt) + ax = previewAxesUnderPointer(); + if isempty(ax) + return; + end + + point = ax.CurrentPoint; + x = point(1, 1); + y = point(1, 2); + imageSize = axesImageSize(ax); + if isempty(imageSize) || ~insideImageBounds(x, y, imageSize) + return; + end + zoomAxesAtPoint(ax, x, y, evt.VerticalScrollCount, imageSize); + end + + function ax = previewAxesUnderPointer() + ax = []; + try + hit = hittest(fig); + ax = ancestor(hit, 'matlab.ui.control.UIAxes'); + catch + ax = []; + end + if isequal(ax, ui.topAxes) || isequal(ax, ui.bottomAxes) + return; + end + ax = []; + end + + function onSaveMask(~, ~) + if isempty(S.maskImage) + [boundaryMask, ok] = currentBoundaryMask(false); + if ~ok + uialert(fig, 'Draw a mask ROI or add a boundary to the mask canvas before saving.', 'Save ROI mask'); + return; + end + S.maskImage = boundaryMask; + end + + [folder, name] = fileparts(char(S.referencePath)); + if isempty(folder) + folder = pwd; + end + defaultName = fullfile(folder, [name '_roi_mask.png']); + [f, p] = uiputfile({'*.png', 'PNG mask'}, 'Save ROI mask', defaultName); + if isequal(f, 0) + addLog('Save ROI mask cancelled.'); + return; + end + + out = fullfile(p, f); + imwrite(S.maskImage, out); + addLog(sprintf('Saved ROI mask: %s', out)); + end + + function ok = previewMaskImage(showAlert) + [boundaryMask, ok] = currentBoundaryMask(showAlert); + if ok + ddPreview.Value = 'ROI mask'; + showImage(ui.bottomAxes, maskRgb(boundaryMask), 'ROI boundary preview'); + updateMaskCurveGraphics(); + addLog(sprintf('Previewed %s ROI boundary with %d anchors.', ... + char(S.maskBoundaryStyle), size(S.maskPoints, 1))); + txtDetails.Value = {'Boundary preview updated. Add it to the mask canvas, subtract it, or keep editing anchors.'}; + refreshSummary(); + return; + end + if ~isempty(S.maskImage) + ddPreview.Value = 'ROI mask'; + showMaskCanvas('ROI mask canvas'); + ok = true; + end + end + + function [boundaryMask, ok] = currentBoundaryMask(showAlert) + ok = false; + boundaryMask = []; + if size(S.maskPoints, 1) < 3 + if showAlert + uialert(fig, 'Mask ROI needs at least three anchors.', 'Not enough anchors'); + end + return; + end + if ~isempty(S.maskEditor) + curve = S.maskEditor.curvePoints(); + boundaryMask = maskFromCurve(curve, size(S.currentReferenceImage)); + else + boundaryMask = boundaryMaskImage(S.maskPoints, size(S.currentReferenceImage), S.maskBoundaryStyle); + end + ok = true; + end + + function canvas = maskCanvas() + if isempty(S.maskImage) + canvas = zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 'uint8'); + else + canvas = S.maskImage; + end + end + + function showMaskCanvas(titleText) + if isempty(S.maskImage) + mask = zeros(size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), 'uint8'); + else + mask = S.maskImage; + end + ddPreview.Value = 'ROI mask'; + showImage(ui.bottomAxes, maskRgb(mask), titleText); + updateMaskEditControls(); + end + + function pushMaskHistory(description) + snapshot = struct( ... + 'maskImage', S.maskImage, ... + 'maskPoints', S.maskPoints, ... + 'description', description); + S.maskHistory(end+1) = snapshot; + maxUndoSteps = 20; + if numel(S.maskHistory) > maxUndoSteps + S.maskHistory = S.maskHistory((end - maxUndoSteps + 1):end); + end + updateMaskEditControls(); + end + + function refreshPreview() + clearCropRoi(); + clearMaskRoi(); + resetPreviewAxes(); + if strcmp(ddPreview.Value, 'Current pair') + showCurrentPair(); + refreshSummary(); + return; + elseif strcmp(ddPreview.Value, 'Original pair') + showOriginalPair(); + refreshSummary(); + return; + elseif strcmp(ddPreview.Value, 'ROI mask') + if ~isempty(S.currentReferenceImage) + showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); + end + if ~isempty(S.maskImage) + showImage(ui.bottomAxes, maskRgb(S.maskImage), 'ROI mask'); + end + refreshSummary(); + return; + elseif ~isempty(S.currentReferenceImage) + showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); + end + + previewImage = []; + previewTitle = ddPreview.Value; + switch ddPreview.Value + case 'Current moving image' + previewImage = S.currentMovingImage; + case 'False-color overlay' + if hasImagePair() + previewImage = makeFalseColorOverlay(S.currentReferenceImage, S.currentMovingImage); + end + end + + if ~isempty(previewImage) + showImage(ui.bottomAxes, previewImage, previewTitle); + end + refreshSummary(); + end + + function refreshSummary() + lines = {}; + lines{end+1} = sprintf('Reference: %s', displayPath(S.referencePath)); + lines{end+1} = sprintf('Moving: %s', displayPath(S.movingPath)); + lines{end+1} = sprintf('Current pair: %s', ternary(hasImagePair(), currentPairSizeText(), 'not loaded')); + lines{end+1} = sprintf('Undo steps: %d', numel(S.history)); + lines{end+1} = sprintf('Last aligned image: %s', ternary(~isempty(S.alignedImage), 'available', 'not generated')); + lines{end+1} = sprintf('ROI mask: %s', ternary(~isempty(S.maskImage), 'available', 'not drawn')); + txtSummary.Value = lines; + updateUndoButton(); + end + + function tf = hasImagePair() + tf = ~isempty(S.currentReferenceImage) && ~isempty(S.currentMovingImage); + end + + function txt = currentPairSizeText() + if ~hasImagePair() + txt = 'not loaded'; + return; + end + txt = sprintf('reference %d x %d, moving %d x %d', ... + size(S.currentReferenceImage, 1), size(S.currentReferenceImage, 2), ... + size(S.currentMovingImage, 1), size(S.currentMovingImage, 2)); + end + + function showCurrentPair() + resetPreviewAxes(); + if ~isempty(S.currentReferenceImage) + showImage(ui.topAxes, S.currentReferenceImage, 'Current reference'); + end + if ~isempty(S.currentMovingImage) + showImage(ui.bottomAxes, S.currentMovingImage, 'Current moving'); + end + end + + function showOriginalPair() + resetPreviewAxes(); + if ~isempty(S.referenceImage) + showImage(ui.topAxes, S.referenceImage, 'Original reference'); + end + if ~isempty(S.movingImage) + showImage(ui.bottomAxes, S.movingImage, 'Original moving'); + end + end + + function clearCropRoi() + for iListener = 1:numel(S.cropRoiListeners) + deleteIfValid(S.cropRoiListeners{iListener}); + end + S.cropRoiListeners = {}; + deleteIfValid(S.cropRoiTop); + deleteIfValid(S.cropRoiBottom); + S.cropRoiTop = []; + S.cropRoiBottom = []; + btnApplyCrop.Enable = 'off'; + btnCancelCrop.Enable = 'off'; + end + + function clearMaskRoi() + if ~isempty(S.maskEditor) + S.maskEditor.delete(); + end + S.maskEditor = []; + S.maskPoints = []; + setMaskEditControls(false); + end + + function resetWorkflowStateForNewInput() + if ~isempty(S.referenceImage) + S.currentReferenceImage = S.referenceImage; + end + if ~isempty(S.movingImage) + S.currentMovingImage = S.movingImage; + end + S.alignedImage = []; + S.cropReference = []; + S.cropMoving = []; + S.cropRect = []; + S.maskImage = []; + S.maskPoints = []; + S.maskHistory = S.maskHistory([]); + S.history = S.history([]); + clearCropRoi(); + clearMaskRoi(); + updateUndoButton(); + end + + function chooseDefaultPreviewAfterLoad() + if hasImagePair() + ddPreview.Value = 'False-color overlay'; + else + ddPreview.Value = 'Current pair'; + end + end + + function pushHistory(description) + if ~hasImagePair() + return; + end + snapshot = struct( ... + 'reference', S.currentReferenceImage, ... + 'moving', S.currentMovingImage, ... + 'aligned', S.alignedImage, ... + 'cropReference', S.cropReference, ... + 'cropMoving', S.cropMoving, ... + 'maskImage', S.maskImage, ... + 'maskPoints', S.maskPoints, ... + 'description', description); + S.history(end+1) = snapshot; + maxUndoSteps = 12; + if numel(S.history) > maxUndoSteps + S.history = S.history((end - maxUndoSteps + 1):end); + end + updateUndoButton(); + end + + function clearOperationDerivedState() + S.maskImage = []; + S.maskPoints = []; + S.maskHistory = S.maskHistory([]); + clearMaskRoi(); + end + + function updateUndoButton() + btnUndoEdit.Enable = ternary(~isempty(S.history), 'on', 'off'); + end + + function folder = defaultSaveFolder() + [folder, ~] = fileparts(char(S.referencePath)); + if isempty(folder) + [folder, ~] = fileparts(char(S.movingPath)); + end + if isempty(folder) + folder = pwd; + end + end + + function resetPreviewAxes() + labkit.ui.view.draw(ui.topAxes, 'reset', 'Reference', true); + labkit.ui.view.draw(ui.bottomAxes, 'reset', 'Current Preview', true); + end + + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end +end diff --git a/apps/dic/private/showImage.m b/apps/dic/private/showImage.m new file mode 100644 index 0000000..eb1bafb --- /dev/null +++ b/apps/dic/private/showImage.m @@ -0,0 +1,5 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function hImage = showImage(ax, imageData, titleText) + hImage = labkit.ui.view.draw(ax, 'image', imageData, titleText); +end diff --git a/apps/dic/private/squareRectInsideImage.m b/apps/dic/private/squareRectInsideImage.m new file mode 100644 index 0000000..720399d --- /dev/null +++ b/apps/dic/private/squareRectInsideImage.m @@ -0,0 +1,23 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function rect = squareRectInsideImage(roi, imageSize) + x = roi(1); + y = roi(2); + w = roi(3); + h = roi(4); + side = round(max(w, h)); + side = max(side, 1); + maxSide = max(1, min(imageSize(1), imageSize(2)) - 1); + side = min(side, maxSide); + + cx = x + w / 2; + cy = y + h / 2; + xSq = round(cx - side / 2); + ySq = round(cy - side / 2); + + maxX = max(1, imageSize(2) - side); + maxY = max(1, imageSize(1) - side); + xSq = min(max(1, xSq), maxX); + ySq = min(max(1, ySq), maxY); + rect = [xSq, ySq, side, side]; +end diff --git a/apps/dic/private/strainToRgb.m b/apps/dic/private/strainToRgb.m new file mode 100644 index 0000000..d6eafdd --- /dev/null +++ b/apps/dic/private/strainToRgb.m @@ -0,0 +1,18 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function [rgb, validMask] = strainToRgb(strainMap, validMap, targetSize, opts) + S = extendStrainMapToRoi(double(strainMap), validMap); + if opts.sigmaSmooth > 0 + S = imgaussfilt(S, opts.sigmaSmooth); + end + Sbig = imresize(S, opts.oversample, 'lanczos3'); + Shr = imresize(Sbig, targetSize, 'lanczos3'); + validMask = imresize(logical(validMap), targetSize, 'nearest') & isfinite(Shr); + smin = opts.colorRange(1); + smax = opts.colorRange(2); + Snorm = (Shr - smin) ./ (smax - smin); + Snorm = max(min(Snorm, 1), 0); + idx = ones(size(Snorm)); + idx(validMask) = round(Snorm(validMask) * (size(opts.colormap, 1) - 1)) + 1; + rgb = ind2rgb(idx, opts.colormap); +end diff --git a/apps/dic/private/strainValidMask.m b/apps/dic/private/strainValidMask.m new file mode 100644 index 0000000..f8f5e11 --- /dev/null +++ b/apps/dic/private/strainValidMask.m @@ -0,0 +1,10 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function validMap = strainValidMask(strainMap, roiMask, displayMask) + validMap = isfinite(strainMap); + if ~isempty(roiMask) + validMap = validMap & logical(roiMask); + else + validMap = validMap & imresize(logical(displayMask), size(strainMap), 'nearest'); + end +end diff --git a/apps/dic/private/summarizeStrain.m b/apps/dic/private/summarizeStrain.m new file mode 100644 index 0000000..320ae73 --- /dev/null +++ b/apps/dic/private/summarizeStrain.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function T = summarizeStrain(strain, mask) + exx = strain.exx(mask); + eyy = strain.eyy(mask); + metric = ["Mean"; "Std"; "Median"; "Min"; "Max"]; + exxValues = nanSafeStats(exx); + eyyValues = nanSafeStats(eyy); + T = table(metric, exxValues, eyyValues, ... + 'VariableNames', {'Metric', 'EXX', 'EYY'}); +end diff --git a/apps/dic/private/summaryMaskForStrain.m b/apps/dic/private/summaryMaskForStrain.m new file mode 100644 index 0000000..a86170e --- /dev/null +++ b/apps/dic/private/summaryMaskForStrain.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function mask = summaryMaskForStrain(strain, overlayMask) + if ~isempty(strain.roiMask) + mask = logical(strain.roiMask); + else + mask = imresize(logical(overlayMask), size(strain.exx), 'nearest'); + end +end diff --git a/apps/dic/private/summaryTableData.m b/apps/dic/private/summaryTableData.m new file mode 100644 index 0000000..4572172 --- /dev/null +++ b/apps/dic/private/summaryTableData.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function data = summaryTableData(T) + if isempty(T) || height(T) == 0 + data = {}; + return; + end + data = [cellstr(T.Metric), num2cell(T.EXX), num2cell(T.EYY)]; +end diff --git a/apps/dic/private/tagFromPath.m b/apps/dic/private/tagFromPath.m new file mode 100644 index 0000000..13ddaeb --- /dev/null +++ b/apps/dic/private/tagFromPath.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPostprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function tag = tagFromPath(filepath) + tokens = regexp(filepath, '(\d+(?:\.\d+)?mm)', 'tokens'); + if isempty(tokens) + tag = 'unknown_mm'; + else + tag = tokens{end}{1}; + end + tag = regexprep(tag, '[^A-Za-z0-9_.-]', '_'); +end diff --git a/apps/dic/private/ternary.m b/apps/dic/private/ternary.m new file mode 100644 index 0000000..9e5dbed --- /dev/null +++ b/apps/dic/private/ternary.m @@ -0,0 +1,9 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function txt = ternary(cond, trueText, falseText) + if cond + txt = trueText; + else + txt = falseText; + end +end diff --git a/apps/dic/private/transformMatrix.m b/apps/dic/private/transformMatrix.m new file mode 100644 index 0000000..12c36b6 --- /dev/null +++ b/apps/dic/private/transformMatrix.m @@ -0,0 +1,11 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function T = transformMatrix(tform) + if isprop(tform, 'T') + T = tform.T; + elseif isprop(tform, 'A') + T = tform.A; + else + T = eye(3); + end +end diff --git a/apps/dic/private/transformSummary.m b/apps/dic/private/transformSummary.m new file mode 100644 index 0000000..c989868 --- /dev/null +++ b/apps/dic/private/transformSummary.m @@ -0,0 +1,12 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function lines = transformSummary(tform, referenceSize, movingSize) + T = transformMatrix(tform); + lines = { ... + sprintf('Reference size: %d x %d', referenceSize(1), referenceSize(2)), ... + sprintf('Moving size: %d x %d', movingSize(1), movingSize(2)), ... + 'Rigid transform matrix:', ... + sprintf('[%.6g %.6g %.6g]', T(1, 1), T(1, 2), T(1, 3)), ... + sprintf('[%.6g %.6g %.6g]', T(2, 1), T(2, 2), T(2, 3)), ... + sprintf('[%.6g %.6g %.6g]', T(3, 1), T(3, 2), T(3, 3))}; +end diff --git a/apps/dic/private/wrapIndex.m b/apps/dic/private/wrapIndex.m new file mode 100644 index 0000000..364ba70 --- /dev/null +++ b/apps/dic/private/wrapIndex.m @@ -0,0 +1,5 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function idx = wrapIndex(idx, n) + idx = mod(idx - 1, n) + 1; +end diff --git a/apps/dic/private/zoomAxesAtPoint.m b/apps/dic/private/zoomAxesAtPoint.m new file mode 100644 index 0000000..b62ea0a --- /dev/null +++ b/apps/dic/private/zoomAxesAtPoint.m @@ -0,0 +1,31 @@ +% App-owned DIC helper extracted from labkit_DICPreprocess_app.m. Expected caller: DIC app entrypoints. +% Inputs, outputs, and side effects match the original local helper implementation. +function zoomAxesAtPoint(ax, x, y, scrollCount, imageSize) + if scrollCount == 0 + return; + end + + fullX = [0.5, imageSize(2) + 0.5]; + fullY = [0.5, imageSize(1) + 0.5]; + zoomFactor = 1.20 ^ scrollCount; + + currentX = ax.XLim; + currentY = ax.YLim; + newWidth = diff(currentX) * zoomFactor; + newHeight = diff(currentY) * zoomFactor; + + minSpan = 10; + newWidth = min(max(newWidth, minSpan), diff(fullX)); + newHeight = min(max(newHeight, minSpan), diff(fullY)); + + xFrac = (x - currentX(1)) / max(eps, diff(currentX)); + yFrac = (y - currentY(1)) / max(eps, diff(currentY)); + xFrac = min(max(xFrac, 0), 1); + yFrac = min(max(yFrac, 0), 1); + + newX = [x - xFrac * newWidth, x + (1 - xFrac) * newWidth]; + newY = [y - yFrac * newHeight, y + (1 - yFrac) * newHeight]; + + ax.XLim = clampLimits(newX, fullX); + ax.YLim = clampLimits(newY, fullY); +end diff --git a/apps/electrochem/electrochemWorkflow.m b/apps/electrochem/electrochemWorkflow.m new file mode 100644 index 0000000..40b0958 --- /dev/null +++ b/apps/electrochem/electrochemWorkflow.m @@ -0,0 +1,23 @@ +function varargout = electrochemWorkflow(appKey, command, varargin) +%ELECTROCHEMWORKFLOW Dispatch app-owned electrochem workflow helpers. +% Expected caller: electrochem app tests and migration-time workflow checks. +% Inputs are an app key, a workflow command, and command-specific arguments. +% Outputs match the selected app-owned helper. File side effects are limited to +% CSV export commands. + + switch string(appKey) + case "chronoOverlay" + [varargout{1:nargout}] = chronoOverlayWorkflow(command, varargin{:}); + case "cic" + [varargout{1:nargout}] = cicWorkflow(command, varargin{:}); + case "csc" + [varargout{1:nargout}] = cscWorkflow(command, varargin{:}); + case "eis" + [varargout{1:nargout}] = eisWorkflow(command, varargin{:}); + case "vtResistance" + [varargout{1:nargout}] = vtResistanceWorkflow(command, varargin{:}); + otherwise + error('labkit:Electrochem:UnknownWorkflow', ... + 'Unknown electrochem workflow key: %s.', appKey); + end +end diff --git a/apps/electrochem/labkit_CIC_app.m b/apps/electrochem/labkit_CIC_app.m index cee3b35..0baa39a 100644 --- a/apps/electrochem/labkit_CIC_app.m +++ b/apps/electrochem/labkit_CIC_app.m @@ -24,7 +24,7 @@ % - By default, the evaluation point is 10 us after the end of each phase, % matching the convention commonly used in the literature the user shared. [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_CIC_app', varargin, nargout, cicAppTestHandlers()); + 'labkit_CIC_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -38,1345 +38,11 @@ error('labkit_CIC_app:TooManyOutputs', 'labkit_CIC_app returns at most the app figure handle.'); end - S = struct(); - S.session = labkit.dta.makeSession('cic_vt'); - S.items = S.session.items; % loaded files + parsed content + analysis - S.current = []; - - %% ===================== Figure & Layout ===================== - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry CIC GUI (Voltage Transient)', ... - 'position', [40 30 1680 980], ... - 'leftWidth', 430, ... - 'options', struct('rightKind', 'dualPlot'))); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - %% ===================== File panel ===================== - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onClearAll = @(~,~) clearAllFiles(); - fileCallbacks.onExport = @(~,~) exportResultsCSV(); - fileCallbacks.onSelectFile = @(~,~) onSelectFile(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Export results CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - %% ===================== Analysis settings ===================== - settingsUi = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [9 2]); - gs = settingsUi.grid; - - uilabel(gs,'Text','Window preset:','HorizontalAlignment','right'); - ddPreset = uidropdown(gs, ... - 'Items',{'Pt (-0.6 to 0.8 V)','PEDOT:PSS (-0.9 to 0.6 V)','Custom'}, ... - 'Value','Pt (-0.6 to 0.8 V)', ... - 'ValueChangedFcn',@(~,~) onPresetChanged()); - ddPreset.Layout.Row = 1; ddPreset.Layout.Column = 2; - - [lblCathLim, edCathLim] = labkit.ui.view.form(gs, 'spinner', 'Cathodic limit (V):', ... - 'Value', -0.6, 'Limits', [-10 10], 'Step', 0.01, ... - 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); - lblCathLim.Layout.Row = 2; lblCathLim.Layout.Column = 1; - edCathLim.Layout.Row = 2; edCathLim.Layout.Column = 2; - - [lblAnodLim, edAnodLim] = labkit.ui.view.form(gs, 'spinner', 'Anodic limit (V):', ... - 'Value', 0.8, 'Limits', [-10 10], 'Step', 0.01, ... - 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); - lblAnodLim.Layout.Row = 3; lblAnodLim.Layout.Column = 1; - edAnodLim.Layout.Row = 3; edAnodLim.Layout.Column = 2; - - [lblDelayUs, edDelayUs] = labkit.ui.view.form(gs, 'spinner', 'Sample delay after pulse end:', ... - 'Value', 10, 'Limits', [0 inf], 'Step', 1, ... - 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); - lblDelayUs.Layout.Row = 4; lblDelayUs.Layout.Column = 1; - edDelayUs.Layout.Row = 4; edDelayUs.Layout.Column = 2; - - uilabel(gs,'Text','Area override (cm^2):','HorizontalAlignment','right'); - edArea = uieditfield(gs,'text','Value','', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - edArea.Layout.Row = 5; edArea.Layout.Column = 2; - - uilabel(gs,'Text','Pulse detection:','HorizontalAlignment','right'); - ddPulseMode = uidropdown(gs, ... - 'Items',{'Metadata first, then auto','Metadata only','Auto from Im only'}, ... - 'Value','Metadata first, then auto', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddPulseMode.Layout.Row = 6; ddPulseMode.Layout.Column = 2; - - uilabel(gs,'Text','CIC summary mode:','HorizontalAlignment','right'); - ddCICMode = uidropdown(gs, ... - 'Items',{'Cathodic phase','Anodic phase','Total biphasic'}, ... - 'Value','Total biphasic', ... - 'ValueChangedFcn',@(~,~) refreshResultsSummary()); - ddCICMode.Layout.Row = 7; ddCICMode.Layout.Column = 2; - - uilabel(gs,'Text','CIC unit:','HorizontalAlignment','right'); - ddCICUnit = uidropdown(gs, ... - 'Items',{'mC/cm^2','uC/cm^2'}, ... - 'Value','mC/cm^2', ... - 'ValueChangedFcn',@(~,~) refreshCICUnitDisplays()); - ddCICUnit.Layout.Row = 8; ddCICUnit.Layout.Column = 2; - - cbUseMeasuredCurrent = uicheckbox(gs,'Text','Use measured Im integration for charge (recommended)', ... - 'Value',true,'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - cbUseMeasuredCurrent.Layout.Row = 9; cbUseMeasuredCurrent.Layout.Column = [1 2]; - - %% ===================== Quick info ===================== - infoUi = labkit.ui.view.section(laySR, 'Current File Summary', 1, [11 2]); - gi = infoUi.grid; - - S.txtControlMode = labkit.ui.view.form(gi, 'info', 1, 'Control mode:'); - S.txtDetect = labkit.ui.view.form(gi, 'info', 2, 'Detection:'); - S.txtDelay = labkit.ui.view.form(gi, 'info', 3, 'Delay used:'); - S.txtArea = labkit.ui.view.form(gi, 'info', 4, 'Area:'); - S.txtEmc = labkit.ui.view.form(gi, 'info', 5, 'Emc:'); - S.txtEma = labkit.ui.view.form(gi, 'info', 6, 'Ema:'); - S.txtQc = labkit.ui.view.form(gi, 'info', 7, 'Cathodic Q/CIC:'); - S.txtQa = labkit.ui.view.form(gi, 'info', 8, 'Anodic Q/CIC:'); - S.txtQt = labkit.ui.view.form(gi, 'info', 9, 'Total Q/CIC:'); - S.txtSafe = labkit.ui.view.form(gi, 'info', 10, 'Safety:'); - S.txtBest = labkit.ui.view.form(gi, 'info', 11, 'Best safe among loaded:'); - - %% ===================== Actions ===================== - actionUi = labkit.ui.view.section(layFA, 'Plot / Debug', 3, [2 3]); - ga = actionUi.grid; - - btnRefresh = uibutton(ga,'Text','Refresh plots','ButtonPushedFcn',@(~,~) refreshPlots()); - btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 1; - btnSwap = uibutton(ga,'Text','Swap top / bottom','ButtonPushedFcn',@(~,~) swapPlots()); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 2; - btnReset = uibutton(ga,'Text','Reset axes','ButtonPushedFcn',@(~,~) resetAxes()); - btnReset.Layout.Row = 1; btnReset.Layout.Column = 3; - - cbShowMarkers = uicheckbox(ga,'Text','Show debug markers','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowMarkers.Layout.Row = 2; cbShowMarkers.Layout.Column = 1; - cbShowLimits = uicheckbox(ga,'Text','Show window limits','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowLimits.Layout.Row = 2; cbShowLimits.Layout.Column = 2; - cbShowShading = uicheckbox(ga,'Text','Shade pulse windows','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowShading.Layout.Row = 2; cbShowShading.Layout.Column = 3; - - %% ===================== Results table ===================== - tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, ... - {'File','Amp(A)','Emc(V)','Ema(V)','Qc(mC/cm^2)','Qa(mC/cm^2)','Qtot(mC/cm^2)','Safe'}, ... - cell(0,8)); - tbl = tableUi.table; - - %% ===================== Log ===================== - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - - %% ===================== Right: plots ===================== - topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); - bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); - plotControls = labkit.ui.view.panel( ... - ui.topControlsPanel, ... - 'topBottomPlotControls', ... - ui.bottomControlsPanel, ... - {'Time (s)', 'Sample #'}, ... - {'VT: Vf vs time', 'IT: Im vs time'}, ... - topPlotDefaults, ... - bottomPlotDefaults, ... - @(~,~) refreshPlots()); - ddTopX = plotControls.topX; - ddTopY = plotControls.topY; - cbTopGrid = plotControls.topGridCheckbox; - axTop = ui.topAxes; - ddBotX = plotControls.bottomX; - ddBotY = plotControls.bottomY; - cbBotGrid = plotControls.bottomGridCheckbox; - axBottom = ui.bottomAxes; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('CIC debug trace enabled.'); - debugLog.instrumentFigure(fig); - end + fig = runCICApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - onPresetChanged(); - - %% App callbacks, session actions, refresh, plotting, and export - function onPresetChanged() - switch ddPreset.Value - case 'Pt (-0.6 to 0.8 V)' - edCathLim.Value = -0.6; - edAnodLim.Value = 0.8; - case 'PEDOT:PSS (-0.9 to 0.6 V)' - edCathLim.Value = -0.9; - edAnodLim.Value = 0.6; - otherwise - % keep manual values - end - analyzeCurrentFile(); - end - - function onOpenFiles(~,~) - [f,p] = uigetfile({'*.DTA;*.dta','Gamry DTA (*.DTA)';'*.*','All files'}, ... - 'Select one or more Gamry DTA files','MultiSelect','on'); - if isequal(f,0) - addLog('Open cancelled.'); - return; - end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadDTAFiles(filepaths); - end - - function onOpenFolder(~,~) - folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); - if isequal(folder,0) - addLog('Folder selection cancelled.'); - return; - end - - filepaths = labkit.dta.findFiles(folder); - if isempty(filepaths) - addLog(sprintf('No DTA files found under: %s', folder)); - uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); - return; - end - - addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); - loadDTAFiles(filepaths); - end - - function loadDTAFiles(filepaths) - if isempty(filepaths) - return; - end - - filepaths = unique(filepaths, 'stable'); - callbacks = struct(); - callbacks.onAdded = @(~, ~) []; - callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); - callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); - [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); - postProcessAddedItems(report.added); - S.items = S.session.items; - - refreshFileList(); - restoreDefaultPlotSelections(); - resetAxesToDefaultState(); - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - - if ~isempty(report.failed) - firstError = report.failed(1); - uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); - end - end - - function postProcessAddedItems(filepaths) - for iFile = 1:numel(filepaths) - idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); - if isempty(idx) - continue; - end - item = S.session.items(idx); - item.analysis = []; - - for ii = 1:numel(item.logmsg) - addLog(item.logmsg{ii}); - end - - item = analyzeItem(item); - S.session.items(idx) = item; - addLog(sprintf('Loaded: %s', filepaths{iFile})); - end - end - - function analyzeCurrentFile() - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - refreshResultsSummary(); - refreshPlots(); - return; - end - S.items(S.current) = analyzeItem(S.items(S.current)); - S.session.items = S.items; - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - end - - function item = analyzeItem(item) - opts = struct(); - opts.delay_s = edDelayUs.Value * 1e-6; - opts.cathLimit = edCathLim.Value; - opts.anodLimit = edAnodLim.Value; - opts.areaOverride = edArea.Value; - opts.pulseMode = ddPulseMode.Value; - opts.usedMeasuredCurrent = cbUseMeasuredCurrent.Value; - - A = computeCIC(item, opts); - item.analysis = A; - if A.ok - addLog(sprintf('%s: Emc=%.6f V, Ema=%.6f V, safe=%d', item.name, A.Emc, A.Ema, A.safe)); - elseif isfield(A, 'logOnFailure') && A.logOnFailure - addLog(sprintf('%s: %s', item.name, A.message)); - end - end - - function onSelectFile() - if isempty(lbFiles.Items) - S.current = []; - resetAxesToDefaultState(); - refreshResultsSummary(); - refreshPlots(); - return; - end - - idx = find(strcmp(lbFiles.Items, lbFiles.Value), 1); - if isempty(idx) - S.current = []; - else - S.current = idx; - end - - restoreDefaultPlotSelections(); - resetAxesToDefaultState(); - refreshResultsSummary(); - refreshPlots(); - end - - function clearAllFiles() - S.session = labkit.dta.makeSession('cic_vt'); - S.items = S.session.items; - S.current = []; - restoreDefaultPlotSelections(); - resetAxesToDefaultState(); - refreshFileList(); - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - addLog('Cleared all files.'); - end - - function refreshFileList() - if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); - txtLoaded.Value = fileLabels.loadedText; - S.current = []; - return; - end - - names = {S.items.name}; - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); - S.current = idx(1); - txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); - end - - function refreshBatchTable() - [~, unitLabel] = cicDisplayUnit(); - [C, columnNames] = buildBatchTableData(S.items, unitLabel); - tbl.ColumnName = columnNames; - if isempty(S.items) - tbl.Data = cell(0,8); - return; - end - tbl.Data = C; - end - - function refreshResultsSummary() - % clear first - S.txtControlMode.Value = '-'; - S.txtDetect.Value = '-'; - S.txtDelay.Value = '-'; - S.txtArea.Value = '-'; - S.txtEmc.Value = '-'; - S.txtEma.Value = '-'; - S.txtQc.Value = '-'; - S.txtQa.Value = '-'; - S.txtQt.Value = '-'; - S.txtSafe.Value = '-'; - S.txtBest.Value = bestSafeString(); - - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - return; - end - - it = S.items(S.current); - S.txtControlMode.Value = chronoControlModeText(it); - if isempty(it.analysis) || ~it.analysis.ok - if ~isempty(it.analysis) && isfield(it.analysis,'message') - S.txtSafe.Value = it.analysis.message; - else - S.txtSafe.Value = 'No valid analysis'; - end - S.txtBest.Value = bestSafeString(); - return; - end - - A = it.analysis; - S.txtDetect.Value = sprintf('%s | %s', A.detectMode, A.detectMsg); - S.txtDelay.Value = sprintf('%.3f us', 1e6 * A.delay_s); - S.txtArea.Value = formatMaybeNum(A.area_cm2,'%.8g cm^2'); - S.txtEmc.Value = sprintf('%.6f V @ %.6fus', A.Emc, 1e6*A.t_emc); - S.txtEma.Value = sprintf('%.6f V @ %.6fus', A.Ema, 1e6*A.t_ema); - S.txtQc.Value = formatChargeDensity(A.Qc_C, A.CICc_mCcm2, ddCICUnit.Value); - S.txtQa.Value = formatChargeDensity(A.Qa_C, A.CICa_mCcm2, ddCICUnit.Value); - S.txtQt.Value = formatChargeDensity(A.Qt_C, A.CICt_mCcm2, ddCICUnit.Value); - S.txtSafe.Value = sprintf('%s | Emc>=%.3f? %d | Ema<=%.3f? %d', ... - ternary(A.safe,'SAFE','UNSAFE'), A.cathLimit, A.cathOK, A.anodLimit, A.anodOK); - S.txtBest.Value = bestSafeString(); - end - - function out = chronoControlModeText(item) - out = 'Unknown chrono control mode'; - if ~isfield(item, 'controlMode') - return; - end - - switch string(item.controlMode) - case "current" - out = 'Current-controlled chrono'; - case "voltage" - out = 'Voltage-controlled chrono'; - otherwise - out = 'Unknown chrono control mode'; - end - end - - function out = bestSafeString() - if isempty(S.items) - out = '-'; - return; - end - safeIdx = []; - vals = []; - for i = 1:numel(S.items) - if ~isempty(S.items(i).analysis) && S.items(i).analysis.ok && S.items(i).analysis.safe - safeIdx(end+1) = i; %#ok - vals(end+1) = selectedCICValue(S.items(i).analysis); %#ok - end - end - if isempty(safeIdx) - out = 'No safe file in current batch'; - return; - end - [~, imax] = max(vals); - ii = safeIdx(imax); - [scale, unitLabel] = cicDisplayUnit(); - out = sprintf('%s | %s = %.6g %s', S.items(ii).name, shortModeName(), scale * vals(imax), unitLabel); - end - - function refreshCICUnitDisplays() - refreshBatchTable(); - refreshResultsSummary(); - end - - function [scale, unitLabel] = cicDisplayUnit() - unitLabel = ddCICUnit.Value; - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end - end - - function v = selectedCICValue(A) - switch ddCICMode.Value - case 'Cathodic phase' - v = A.CICc_mCcm2; - case 'Anodic phase' - v = A.CICa_mCcm2; - otherwise - v = A.CICt_mCcm2; - end - end - - function s = shortModeName() - switch ddCICMode.Value - case 'Cathodic phase' - s = 'CICc'; - case 'Anodic phase' - s = 'CICa'; - otherwise - s = 'CICtotal'; - end - end - - function refreshPlots() - labkit.ui.view.draw(axTop, 'clear'); - labkit.ui.view.draw(axBottom, 'clear'); - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - title(axTop,'Top Plot'); - title(axBottom,'Bottom Plot'); - return; - end - - it = S.items(S.current); - if isempty(it.analysis) || ~it.analysis.ok - title(axTop,'Top Plot'); - title(axBottom,'Bottom Plot'); - text(axTop,0.5,0.5,'No valid analysis','Units','normalized','HorizontalAlignment','center'); - return; - end - - A = it.analysis; - plotOneAxis(axTop, A, ddTopX.Value, ddTopY.Value, cbTopGrid.Value); - plotOneAxis(axBottom, A, ddBotX.Value, ddBotY.Value, cbBotGrid.Value); - end - - function plotOneAxis(ax, A, xChoice, yChoice, showGrid) - if strcmp(xChoice,'Sample #') - x = A.pt; - xlab = 'Sample #'; - cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); - cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); - anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); - anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); - emcX = interp1Safe(A.t, A.pt, A.t_emc); - emaX = interp1Safe(A.t, A.pt, A.t_ema); - else - x = A.t; - xlab = 'Time (s)'; - cathStartX = A.pulse.cath_start; - cathEndX = A.pulse.cath_end; - anodStartX = A.pulse.anod_start; - anodEndX = A.pulse.anod_end; - emcX = A.t_emc; - emaX = A.t_ema; - end - - if startsWith(yChoice,'VT') - y = A.Vf; - ylab = 'Vf (V vs Ref.)'; - baseColor = [0 0.4470 0.7410]; - plot(ax, x, y, 'LineWidth',1.25, 'Color', baseColor); - hold(ax,'on'); - - if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); - end - - if cbShowLimits.Value - yline(ax, A.cathLimit, '--', sprintf('Cath limit = %.3f V', A.cathLimit), ... - 'Color',[0.85 0.2 0.2],'LabelHorizontalAlignment','left'); - yline(ax, A.anodLimit, '--', sprintf('Anod limit = %.3f V', A.anodLimit), ... - 'Color',[0.85 0.2 0.2],'LabelHorizontalAlignment','left'); - end - - addBaselineYLines(ax, A); - - if cbShowMarkers.Value - xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); - xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); - xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); - xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); - addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); - end - hold(ax,'off'); - ttl = sprintf('%s | VT | %s', itName(), ternary(A.safe,'SAFE','UNSAFE')); - else - y = A.Im; - ylab = 'Im (A)'; - baseColor = [0.8500 0.3250 0.0980]; - plot(ax, x, y, 'LineWidth',1.25, 'Color', baseColor); - hold(ax,'on'); - - if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); - end - - if cbShowMarkers.Value - xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); - xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); - xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); - xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); - addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); - end - hold(ax,'off'); - ttl = sprintf('%s | IT | |I|max = %.4g A', itName(), A.ampEstimate_A); - end - - title(ax, ttl, 'Interpreter','none'); - xlabel(ax, xlab); - ylabel(ax, ylab); - grid(ax, ternary(showGrid,'on','off')); - end - - function nm = itName() - if isempty(S.items) || isempty(S.current), nm = 'file'; else, nm = S.items(S.current).name; end - end - - function swapPlots() - labkit.ui.view.update(plotControls, 'swapPlotSelections'); - refreshPlots(); - end - - function resetAxes() - resetAxesToDefaultState(); - refreshPlots(); - end - - function restoreDefaultPlotSelections() - labkit.ui.view.update(plotControls, 'setPlotSelections', ... - topPlotDefaults, bottomPlotDefaults); - end - - function resetAxesToDefaultState() - labkit.ui.view.draw(axTop, 'reset', 'Top Plot', true); - labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot', true); - end - - function exportResultsCSV() - if isempty(S.items) - uialert(fig,'No results to export.','Export'); - return; - end - [f,p] = uiputfile('cic_results.csv','Save results CSV'); - if isequal(f,0) - return; - end - out = fullfile(p,f); - [~, unitLabel] = cicDisplayUnit(); - [ok, msg] = writeResultsCSV(S.items, out, unitLabel); - if ~ok - uialert(fig,msg,'Export'); - return; - end - addLog(['Exported CSV: ' out]); - end - - %% ===================== Logging ===================== - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end - -end - -%% App test hook -function handlers = cicAppTestHandlers() - handlers = struct( ... - 'command', {'computeCIC', 'buildBatchTableData', ... - 'buildResultsTable', 'writeResultsCSV'}, ... - 'minArgs', {2, 2, 2, 3}, ... - 'maxArgs', {2, 2, 2, 3}, ... - 'maxOutputs', {1, 2, 1, 2}, ... - 'run', {@runComputeCIC, @runBuildBatchTableData, ... - @runBuildResultsTable, @runWriteResultsCSV}); -end - -function outputs = runComputeCIC(args) - outputs = {computeCIC(args{1}, args{2})}; -end - -function outputs = runBuildBatchTableData(args) - [C, columnNames] = buildBatchTableData(args{1}, args{2}); - outputs = {C, columnNames}; -end - -function outputs = runBuildResultsTable(args) - outputs = {buildResultsTable(args{1}, args{2})}; -end - -function outputs = runWriteResultsCSV(args) - [ok, msg] = writeResultsCSV(args{1}, args{2}, args{3}); - outputs = {ok, msg}; -end - -%% App-local analysis -function A = computeCIC(item, opts) -%COMPUTECIC Compute legacy-compatible CIC / voltage-transient metrics. - - if nargin < 2 - opts = struct(); - end - opts = fillCICOptions(opts); - - A = struct(); - A.ok = false; - A.message = ''; - A.delay_s = opts.delay_s; - A.cathLimit = opts.cathLimit; - A.anodLimit = opts.anodLimit; - A.area_cm2 = chooseArea(item, opts); - A.usedMeasuredCurrent = opts.usedMeasuredCurrent; - A.logOnFailure = false; - - [curve, okCurve, msgCurve] = mainCurve(item); - if ~okCurve - A.message = msgCurve; - A.logOnFailure = true; - return; - end - - t = labkit.dta.getColumn(curve, 'T'); - Vf = labkit.dta.getColumn(curve, 'Vf'); - Im = labkit.dta.getColumn(curve, 'Im'); - pt = labkit.dta.getColumn(curve, 'Pt'); - if isempty(pt) - pt = (0:numel(t)-1).'; - end - - valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); - t = t(valid); - Vf = Vf(valid); - Im = Im(valid); - pt = pt(valid); - if numel(t) < 5 - A.message = 'Not enough valid T/Vf/Im points.'; - return; - end - - A.t = t; - A.Vf = Vf; - A.Im = Im; - A.pt = pt; - A.sample_dt = median(diff(t)); - A.sample_dt_report = A.sample_dt; - A.ampEstimate_A = max(abs(Im)); - - meta = struct(); - if isfield(item, 'meta') - meta = item.meta; - end - [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); - A.pulse = pulse; - A.detectMode = pulse.method; - A.detectMsg = pulseMsg; - - if ~pulse.ok - A.message = pulseMsg; - A.logOnFailure = true; - return; - end - - V = computeVoltageTransientMetrics(t, Vf, pulse, A.delay_s); - A = mergeStructs(A, V); - - Q = computeInjectedCharge(t, Im, pulse, A.usedMeasuredCurrent); - A = mergeStructs(A, Q); - if ~Q.ok - A.message = Q.message; - return; - end - - if isfinite(A.area_cm2) && A.area_cm2 > 0 - A.CICc_mCcm2 = 1e3 * A.Qc_C / A.area_cm2; - A.CICa_mCcm2 = 1e3 * A.Qa_C / A.area_cm2; - A.CICt_mCcm2 = 1e3 * A.Qt_C / A.area_cm2; - else - A.CICc_mCcm2 = NaN; - A.CICa_mCcm2 = NaN; - A.CICt_mCcm2 = NaN; - end - - safety = checkWaterWindowSafety(A.Emc, A.Ema, A.cathLimit, A.anodLimit); - A = mergeStructs(A, safety); - - A.ok = true; - A.message = 'OK'; -end - -function opts = fillCICOptions(opts) - if ~isfield(opts, 'delay_s') - opts.delay_s = 10e-6; - end - if ~isfield(opts, 'cathLimit') - opts.cathLimit = -0.6; - end - if ~isfield(opts, 'anodLimit') - opts.anodLimit = 0.8; - end - if ~isfield(opts, 'areaOverride') - opts.areaOverride = ''; - end - if ~isfield(opts, 'area_cm2') - opts.area_cm2 = NaN; - end - if ~isfield(opts, 'pulseMode') - opts.pulseMode = 'Metadata first, then auto'; - end - if ~isfield(opts, 'usedMeasuredCurrent') - opts.usedMeasuredCurrent = true; - end -end - -function area = chooseArea(item, opts) - area = NaN; - if isfield(opts, 'areaOverride') - area = parsePositiveScalar(opts.areaOverride); - end - if ~isfinite(area) && isfield(opts, 'area_cm2') - area = parsePositiveScalar(opts.area_cm2); - end - if ~isfinite(area) && isfield(item, 'meta') && isfield(item.meta, 'area_cm2') ... - && isfinite(item.meta.area_cm2) && item.meta.area_cm2 > 0 - area = item.meta.area_cm2; - end -end - -function [curve, ok, msg] = mainCurve(item) - if isfield(item, 'curve') && ~isempty(item.curve) - curve = item.curve; - ok = true; - msg = sprintf('Using table: %s', curve.name); - elseif isfield(item, 'tables') - [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); - else - curve = struct(); - ok = false; - msg = 'Main transient table not found.'; - end -end - -function out = mergeStructs(out, in) - names = fieldnames(in); - for i = 1:numel(names) - out.(names{i}) = in.(names{i}); - end -end - -function V = computeVoltageTransientMetrics(t, Vf, pulse, delay_s) - V = struct(); - V.t_emc = pulse.cath_end + delay_s; - V.t_ema = pulse.anod_end + delay_s; - V.emc_idx = nearestIndex(t, V.t_emc); - V.ema_idx = nearestIndex(t, V.t_ema); - V.Emc = interp1Safe(t, Vf, V.t_emc); - V.Ema = interp1Safe(t, Vf, V.t_ema); - - V.Epre = medianInWindow(t, Vf, pulse.pre_start, pulse.pre_end); - V.Ebetween = medianInWindow(t, Vf, pulse.gap_start, pulse.gap_end); - V.Epost = medianInWindow(t, Vf, pulse.post_start, pulse.post_end); - [V.Eipp, V.baselineCathSource, V.baselineCathWindow] = chooseBaselineCandidate( ... - [V.Epre, V.Ebetween, V.Epost, 0], ... - {'pre-pulse median', 'interpulse median', 'post-pulse median', 'zero fallback'}, ... - [pulse.pre_start pulse.pre_end; pulse.gap_start pulse.gap_end; pulse.post_start pulse.post_end; NaN NaN]); - [V.Eipp_gap, V.baselineAnodSource, V.baselineAnodWindow] = chooseBaselineCandidate( ... - [V.Ebetween, V.Epre, V.Epost, V.Eipp], ... - {'interpulse median', 'pre-pulse median', 'post-pulse median', 'cathodic baseline fallback'}, ... - [pulse.gap_start pulse.gap_end; pulse.pre_start pulse.pre_end; pulse.post_start pulse.post_end; V.baselineCathWindow]); - - V.tc_s = max(0, pulse.cath_end - pulse.cath_start); - V.ta_s = max(0, pulse.anod_end - pulse.anod_start); - V.tip_s = max(0, pulse.anod_start - pulse.cath_end); - V.t_conset = pulse.cath_start + delay_s; - V.t_aonset = pulse.anod_start + delay_s; - V.Vc_on = interp1Safe(t, Vf, V.t_conset); - V.Va_on = interp1Safe(t, Vf, V.t_aonset); - V.Va_cath_mag = abs(V.Eipp - V.Vc_on); - V.Va_anod_mag = abs(V.Eipp_gap - V.Va_on); -end - -function Q = computeInjectedCharge(t, Im, pulse, useMeasuredCurrent) - if nargin < 4 - useMeasuredCurrent = true; - end - - Q = struct(); - cathMask = (t >= pulse.cath_start) & (t <= pulse.cath_end); - anodMask = (t >= pulse.anod_start) & (t <= pulse.anod_end); - Q.cathMask = cathMask; - Q.anodMask = anodMask; - - if sum(cathMask) < 2 || sum(anodMask) < 2 - Q.ok = false; - Q.message = 'Pulse windows too short after detection.'; - return; - end - - Q.Ic_est_A = median(Im(cathMask), 'omitnan'); - Q.Ia_est_A = median(Im(anodMask), 'omitnan'); - if ~isfinite(Q.Ic_est_A) - Q.Ic_est_A = pulse.Ic_nominal; - end - if ~isfinite(Q.Ia_est_A) - Q.Ia_est_A = pulse.Ia_nominal; - end - - if useMeasuredCurrent - Qc = abs(trapz(t(cathMask), Im(cathMask))); - Qa = abs(trapz(t(anodMask), Im(anodMask))); - else - Qc = abs(pulse.Ic_nominal * (pulse.cath_end - pulse.cath_start)); - Qa = abs(pulse.Ia_nominal * (pulse.anod_end - pulse.anod_start)); - end - - Q.Qc_C = Qc; - Q.Qa_C = Qa; - Q.Qt_C = Qc + Qa; - Q.ok = true; - Q.message = 'OK'; -end - -function safety = checkWaterWindowSafety(Emc, Ema, cathLimit, anodLimit) - safety = struct(); - safety.cathOK = Emc >= cathLimit; - safety.anodOK = Ema <= anodLimit; - safety.safe = safety.cathOK && safety.anodOK; - - if safety.safe - safety.limitSide = 'safe'; - elseif ~safety.cathOK && ~safety.anodOK - safety.limitSide = 'both exceeded'; - elseif ~safety.cathOK - safety.limitSide = 'cathodic exceeded'; - else - safety.limitSide = 'anodic exceeded'; - end -end - -%% App-local table/export helpers -function [C, columnNames] = buildBatchTableData(items, unitLabel) -%BUILDBATCHTABLEDATA Build legacy CIC batch uitable data. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitLabel] = displayScale(unitLabel); - columnNames = {'File', 'Amp(A)', 'Emc(V)', 'Ema(V)', ... - ['Qc(' unitLabel ')'], ['Qa(' unitLabel ')'], ['Qtot(' unitLabel ')'], 'Safe'}; - - C = cell(numel(items), 8); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.ampEstimate_A; - C{i, 3} = A.Emc; - C{i, 4} = A.Ema; - C{i, 5} = scale * A.CICc_mCcm2; - C{i, 6} = scale * A.CICa_mCcm2; - C{i, 7} = scale * A.CICt_mCcm2; - C{i, 8} = ternary(A.safe, 'safe', A.limitSide); - end -end - -function T = buildResultsTable(items, unitLabel) -%BUILDRESULTSTABLE Build legacy CIC CSV result table. - - if nargin < 2 - unitLabel = 'mC/cm^2'; - end - [scale, unitSuffix] = displayScaleSuffix(unitLabel); - - file = cell(numel(items), 1); - amp_A = NaN(numel(items), 1); - Emc_V = NaN(numel(items), 1); - Ema_V = NaN(numel(items), 1); - Qc_C = NaN(numel(items), 1); - Qa_C = NaN(numel(items), 1); - Qt_C = NaN(numel(items), 1); - CICc = NaN(numel(items), 1); - CICa = NaN(numel(items), 1); - CICt = NaN(numel(items), 1); - safe = zeros(numel(items), 1); - detection = cell(numel(items), 1); - - for i = 1:numel(items) - item = items(i); - file{i} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - detection{i} = 'failed'; - continue; - end - - amp_A(i) = A.ampEstimate_A; - Emc_V(i) = A.Emc; - Ema_V(i) = A.Ema; - Qc_C(i) = A.Qc_C; - Qa_C(i) = A.Qa_C; - Qt_C(i) = A.Qt_C; - CICc(i) = scale * A.CICc_mCcm2; - CICa(i) = scale * A.CICa_mCcm2; - CICt(i) = scale * A.CICt_mCcm2; - safe(i) = A.safe; - detection{i} = A.detectMode; - end - - T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, safe, detection, ... - 'VariableNames', {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... - ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], 'Safe', 'Detection'}); -end - -function [ok, msg] = writeResultsCSV(items, filepath, unitLabel) -%WRITERESULTSCSV Write CIC results in legacy CSV format. - - if nargin < 3 - unitLabel = 'mC/cm^2'; - end - - ok = true; - msg = ''; - - fid = fopen(filepath, 'w'); - if fid < 0 - ok = false; - msg = 'Could not open file for writing.'; - if nargout == 0 - error(msg); - end - return; - end - cleaner = onCleanup(@() fclose(fid)); - - try - T = buildResultsTable(items, unitLabel); - names = T.Properties.VariableNames; - fprintf(fid, 'File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,%s,%s,%s,Safe,Detection\n', ... - names{8}, names{9}, names{10}); - for i = 1:height(T) - if strcmp(T.Detection{i}, 'failed') - fprintf(fid, '"%s",,,,,,,,,,0,"failed"\n', T.File{i}); - else - fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%d,"%s"\n', ... - T.File{i}, T.Amp_A(i), T.Emc_V(i), T.Ema_V(i), T.Qc_C(i), T.Qa_C(i), T.Qt_C(i), ... - T.(names{8})(i), T.(names{9})(i), T.(names{10})(i), T.Safe(i), T.Detection{i}); - end - end - catch ME - ok = false; - msg = ME.message; - if nargout == 0 - rethrow(ME); - end - end -end - -%% App-local plotting helpers -function [v, sourceLabel, window] = chooseBaselineCandidate(candidates, sourceLabels, windows) - v = NaN; - sourceLabel = 'unavailable'; - window = [NaN NaN]; - for k = 1:numel(candidates) - if isfinite(candidates(k)) - v = candidates(k); - sourceLabel = sourceLabels{k}; - if size(windows, 1) >= k - window = windows(k, :); - end - return; - end - end -end - -function [scale, unitLabel] = displayScale(unitLabel) - switch unitLabel - case 'uC/cm^2' - scale = 1e3; - otherwise - scale = 1; - unitLabel = 'mC/cm^2'; - end -end - -function [scale, unitSuffix] = displayScaleSuffix(unitLabel) - [scale, unitLabel] = displayScale(unitLabel); - unitSuffix = regexprep(unitLabel, '[\^/]', ''); -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end - -function out = formatChargeDensity(Q_C, cic_mCcm2, unitLabel) - if isfinite(cic_mCcm2) - switch unitLabel - case 'uC/cm^2' - cic = 1e3 * cic_mCcm2; - otherwise - cic = cic_mCcm2; - unitLabel = 'mC/cm^2'; - end - out = sprintf('%.6e C | %.6f %s', Q_C, cic, unitLabel); - else - out = sprintf('%.6e C | area unavailable', Q_C); - end -end - -function s = formatMaybeNum(v, fmt) - if isfinite(v) - s = sprintf(fmt, v); - else - s = 'NaN'; - end -end - -function txt = ternary(cond, a, b) - if cond - txt = a; - else - txt = b; - end -end - -function shadeWindow(ax, x1, x2, color) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - yl = ylim(ax); - patch(ax,[x1 x2 x2 x1],[yl(1) yl(1) yl(2) yl(2)],color, ... - 'FaceAlpha',0.25,'EdgeColor','none','HandleVisibility','off'); - uistack(findobj(ax,'Type','patch'),'bottom'); -end - -function labelPulseCharge(ax, x1, x2, Q, tagText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - xm = 0.5 * (x1 + x2); - yl = ylim(ax); - y0 = yl(1) + 0.90 * (yl(2) - yl(1)); - text(ax, xm, y0, sprintf('%s = %.3e C', tagText, Q), ... - 'HorizontalAlignment','center','VerticalAlignment','middle', ... - 'BackgroundColor','w','Margin',2); -end - -function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.07*dy; - yMid = yl(1) + 0.55*dy; - yLow = yl(1) + 0.18*dy; - - if strcmp(xChoice,'Sample #') - cOnX = interp1Safe(A.t, A.pt, A.t_conset); - aOnX = interp1Safe(A.t, A.pt, A.t_aonset); - cathBase1 = interp1Safe(A.t, A.pt, A.baselineCathWindow(1)); - cathBase2 = interp1Safe(A.t, A.pt, A.baselineCathWindow(2)); - anodBase1 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(1)); - anodBase2 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(2)); - else - cOnX = A.t_conset; - aOnX = A.t_aonset; - cathBase1 = A.baselineCathWindow(1); - cathBase2 = A.baselineCathWindow(2); - anodBase1 = A.baselineAnodWindow(1); - anodBase2 = A.baselineAnodWindow(2); - end - - plot(ax, emcX, A.Emc, 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); - plot(ax, emaX, A.Ema, 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); - plot(ax, cOnX, A.Vc_on, 's', 'MarkerFaceColor',[0.2 0.6 1.0], 'MarkerEdgeColor','k', 'MarkerSize',6); - plot(ax, aOnX, A.Va_on, 's', 'MarkerFaceColor',[1.0 0.6 0.2], 'MarkerEdgeColor','k', 'MarkerSize',6); - - if isfinite(A.Eipp) - drawBaselineSegment(ax, cathBase1, cathBase2, A.Eipp, [0.25 0.25 0.25], ... - sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), 'bottom'); - end - if isfinite(A.Eipp_gap) - drawBaselineSegment(ax, anodBase1, anodBase2, A.Eipp_gap, [0.45 0.45 0.45], ... - sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), 'top'); - end - - if isfinite(A.Eipp) && isfinite(A.Vc_on) - plot(ax, [cOnX cOnX], [A.Eipp A.Vc_on], '--', 'Color',[0.2 0.6 1.0], 'LineWidth',1.0); - text(ax, cOnX, 0.5*(A.Eipp + A.Vc_on), sprintf(' Va(c)=%.3f V', A.Va_cath_mag), ... - 'Color',[0.15 0.45 0.8], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); - end - if isfinite(A.Eipp_gap) && isfinite(A.Va_on) - plot(ax, [aOnX aOnX], [A.Eipp_gap A.Va_on], '--', 'Color',[0.95 0.55 0.2], 'LineWidth',1.0); - text(ax, aOnX, 0.5*(A.Eipp_gap + A.Va_on), sprintf(' Va(a)=%.3f V', A.Va_anod_mag), ... - 'Color',[0.75 0.35 0.05], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); - end - - text(ax, emcX, A.Emc, sprintf(' Emc = %.4f V', A.Emc), 'VerticalAlignment','bottom', 'Color',[0.1 0.5 0.1]); - text(ax, emaX, A.Ema, sprintf(' Ema = %.4f V', A.Ema), 'VerticalAlignment','top', 'Color',[0.6 0.4 0]); - - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop - 0.06*dy, sprintf('ta = %.3f ms', 1e3*A.ta_s)); - if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yLow, sprintf('tip = %.1f us', 1e6*A.tip_s)); - end - yline(ax, yMid, ':', 'Color',[0.8 0.8 0.8], 'HandleVisibility','off'); -end - -function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) - plot(ax, emcX, interp1Safe(chooseX(A,xChoice), A.Im, emcX), 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); - plot(ax, emaX, interp1Safe(chooseX(A,xChoice), A.Im, emaX), 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); - - plot(ax, [cathStartX cathEndX], [A.Ic_est_A A.Ic_est_A], '--', 'Color',[0.1 0.45 0.8], 'LineWidth',1.3); - plot(ax, [anodStartX anodEndX], [A.Ia_est_A A.Ia_est_A], '--', 'Color',[0.85 0.45 0.1], 'LineWidth',1.3); - text(ax, cathEndX, A.Ic_est_A, sprintf(' ic = %.3f mA', 1e3*A.Ic_est_A), 'Color',[0.1 0.35 0.75], 'VerticalAlignment','bottom'); - text(ax, anodEndX, A.Ia_est_A, sprintf(' ia = %.3f mA', 1e3*A.Ia_est_A), 'Color',[0.7 0.32 0.05], 'VerticalAlignment','top'); - - labelPulseCharge(ax, cathStartX, cathEndX, A.Qc_C, 'Qc'); - labelPulseCharge(ax, anodStartX, anodEndX, A.Qa_C, 'Qa'); - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08*dy; - yMid = yl(2) - 0.16*dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); - drawDurationBracket(ax, anodStartX, anodEndX, yTop, sprintf('ta = %.3f ms', 1e3*A.ta_s)); - if A.tip_s > 0 && anodStartX > cathEndX - drawDurationBracket(ax, cathEndX, anodStartX, yMid, sprintf('tip = %.1f us', 1e6*A.tip_s)); - end -end - -function drawDurationBracket(ax, x1, x2, y, labelText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - yl = ylim(ax); - h = 0.025 * (yl(2) - yl(1)); - plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, 0.5*(x1+x2), y + 1.4*h, labelText, 'HorizontalAlignment','center', ... - 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1); -end - -function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) - if ~isfinite(y) - return; - end - if isfinite(x1) && isfinite(x2) && x2 > x1 - xStart = x1; - xEnd = x2; - else - xl = xlim(ax); - xStart = xl(1) + 0.04 * (xl(2) - xl(1)); - xEnd = xStart + 0.18 * (xl(2) - xl(1)); - end - plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); - text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... - 'BackgroundColor','w', 'Margin',1, 'Interpreter','none'); -end - -function addBaselineYLines(ax, A) - if isfinite(A.Eipp) - yline(ax, A.Eipp, '--', ... - sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), ... - 'Color',[0.20 0.20 0.20], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','bottom'); - end - if isfinite(A.Eipp_gap) - yline(ax, A.Eipp_gap, '--', ... - sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), ... - 'Color',[0.40 0.40 0.40], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','top'); - end -end - -function x = chooseX(A, xChoice) - if strcmp(xChoice, 'Sample #') - x = A.pt; - else - x = A.t; - end -end - -function v = chooseFinite(varargin) - v = NaN; - for k = 1:nargin - if isfinite(varargin{k}) - v = varargin{k}; - return; - end - end -end - -function s = shortBaselineSource(sourceLabel) - switch sourceLabel - case 'pre-pulse median' - s = 'pre'; - case 'interpulse median' - s = 'gap'; - case 'post-pulse median' - s = 'post'; - case 'zero fallback' - s = '0 V fallback'; - case 'cathodic baseline fallback' - s = 'cath fallback'; - otherwise - s = sourceLabel; - end -end - -function q = parsePositiveScalar(x) - if isnumeric(x) - q = x; - else - x = strtrim(char(x)); - if isempty(x) - q = NaN; - return; - end - q = str2double(x); - end - - if ~isscalar(q) || ~isfinite(q) || q <= 0 - q = NaN; - end -end - -function v = interp1Safe(x, y, xq) - if numel(x) < 2 || any(~isfinite([x(:); y(:)])) - v = NaN; - return; - end - - try - v = interp1(x, y, xq, 'linear', 'extrap'); - catch - idx = nearestIndex(x, xq); - v = y(idx); - end -end - -function idx = nearestIndex(x, xq) - [~, idx] = min(abs(x - xq)); -end - -function m = medianInWindow(t, y, t1, t2) - if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 - m = NaN; - return; - end - - mask = t >= t1 & t <= t2; - if ~any(mask) - m = NaN; - else - m = median(y(mask), 'omitnan'); - end end diff --git a/apps/electrochem/labkit_CSC_app.m b/apps/electrochem/labkit_CSC_app.m index 5d41211..6e687b8 100644 --- a/apps/electrochem/labkit_CSC_app.m +++ b/apps/electrochem/labkit_CSC_app.m @@ -20,943 +20,28 @@ % Optional normalization % CSC = Q / area (cm^2); both charge and normalized CSC are shown. % - [testLoadFile, isLoadDiagnostics] = parseCSCLoadDiagnosticsRequest(varargin); - if isLoadDiagnostics - debugLog = labkit.ui.diag.createContext('labkit_CSC_app', struct('enabled', false)); - else - [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_CSC_app', varargin, nargout, cscAppTestHandlers()); - if requestHandled - varargout = requestOutputs; - return; - end + [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... + 'labkit_CSC_app', varargin, nargout); + if requestHandled + varargout = requestOutputs; + return; end if debugLog.enabled if nargout > 2 error('labkit_CSC_app:TooManyOutputs', ... 'labkit_CSC_app debug mode returns at most the app figure and debug log.'); end - elseif ~isLoadDiagnostics && nargout > 1 + elseif nargout > 1 error('labkit_CSC_app:TooManyOutputs', 'labkit_CSC_app returns at most the app figure handle.'); end - if isLoadDiagnostics && nargout == 0 - error('labkit_CSC_app:InvalidTestRequest', 'CSC load test request requires one output diagnostics struct.'); - elseif isLoadDiagnostics && nargout > 1 - error('labkit_CSC_app:TooManyOutputs', 'CSC load test request returns one diagnostics struct.'); - end % Application state container - S = struct(); - S.session = labkit.dta.makeSession('cv_csc'); - S.filepath = ''; - S.items = S.session.items; - S.current = []; - S.curves = struct('name',{},'headers',{},'units',{},'data',{},'numericMask',{}); - S.scanRate = NaN; % V/s - S.currentCurve = 1; - - %% ===================== Figure & Layout ===================== - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry DTA GUI (literature CSC)', ... - 'position', [50 30 1580 950], ... - 'leftWidth', 390, ... - 'options', struct('rightKind', 'dualPlot'))); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - % -------- File panel -------- - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onClearAll = @(~,~) clearAllFiles(); - fileCallbacks.onExport = @(~,~) reloadSelectedFile(); - fileCallbacks.onSelectFile = @(~,~) onSelectFile(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Reload selected', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - % -------- Curve -------- - curveUi = labkit.ui.view.section(layFA, 'Curve', 2, [4 2]); - gf = curveUi.grid; - - uilabel(gf,'Text','File:','HorizontalAlignment','right'); - txtFile = labkit.ui.view.form(gf, 'readonly'); - txtFile.Layout.Row = 1; txtFile.Layout.Column = 2; - - uilabel(gf,'Text','Scan rate:','HorizontalAlignment','right'); - txtScan = labkit.ui.view.form(gf, 'readonly'); - txtScan.Layout.Row = 2; txtScan.Layout.Column = 2; - - uilabel(gf,'Text','Curve:','HorizontalAlignment','right'); - ddCurve = uidropdown(gf,'Items',{'(none)'},'ValueChangedFcn',@(~,~) onCurveChanged()); - ddCurve.Layout.Row = 3; ddCurve.Layout.Column = 2; - - btnAuto = uibutton(gf,'Text','Auto CV + CT','ButtonPushedFcn',@(~,~) autoPresetAndRefresh()); - btnAuto.Layout.Row = 4; btnAuto.Layout.Column = [1 2]; - - % -------- Actions -------- - actionOpts = struct('columnWidth', {{'1x', '1x'}}); - actionUi = labkit.ui.view.section(layFA, 'Actions', 3, [2 2], actionOpts); - ga = actionUi.grid; - - btnSwap = uibutton(ga,'Text','Swap Top/Bottom','ButtonPushedFcn',@(~,~) onSwapPlots()); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 1; - btnCompare = uibutton(ga,'Text','Compare Q / CSC','ButtonPushedFcn',@(~,~) refreshCompare()); - btnCompare.Layout.Row = 1; btnCompare.Layout.Column = 2; - btnRefresh = uibutton(ga,'Text','Refresh Plots','ButtonPushedFcn',@(~,~) refreshPlotsOnly()); - btnRefresh.Layout.Row = 2; btnRefresh.Layout.Column = 1; - btnClear = uibutton(ga,'Text','Clear Both','ButtonPushedFcn',@(~,~) clearBothAxes()); - btnClear.Layout.Row = 2; btnClear.Layout.Column = 2; - - % -------- Comparison / CSC -------- - compUi = labkit.ui.view.section(laySR, 'CSC / Comparison', 1, [8 2]); - gc = compUi.grid; - - uilabel(gc,'Text','Mode:','HorizontalAlignment','right'); - ddMode = uidropdown(gc, ... - 'Items',{'Full','Cathodic','Anodic'}, ... - 'Value','Full', ... - 'ValueChangedFcn',@(~,~) refreshCompare()); - ddMode.Layout.Row = 1; ddMode.Layout.Column = 2; - - uilabel(gc,'Text','Area (cm^2):','HorizontalAlignment','right'); - edArea = uieditfield(gc,'text','Value',''); - edArea.ValueChangedFcn = @(~,~) refreshCompare(); - edArea.Layout.Row = 2; edArea.Layout.Column = 2; - uilabel(gc,'Text','CT charge / CSC:','HorizontalAlignment','right'); - txtQct = labkit.ui.view.form(gc, 'readonly'); - txtQct.Layout.Row = 3; txtQct.Layout.Column = 2; - - uilabel(gc,'Text','CV charge / CSC:','HorizontalAlignment','right'); - txtQcv = labkit.ui.view.form(gc, 'readonly'); - txtQcv.Layout.Row = 4; txtQcv.Layout.Column = 2; - - uilabel(gc,'Text','Difference:','HorizontalAlignment','right'); - txtDiff = labkit.ui.view.form(gc, 'readonly'); - txtDiff.Layout.Row = 5; txtDiff.Layout.Column = 2; - - uilabel(gc,'Text','Relative diff:','HorizontalAlignment','right'); - txtRel = labkit.ui.view.form(gc, 'readonly'); - txtRel.Layout.Row = 6; txtRel.Layout.Column = 2; - - uilabel(gc,'Text','max|dt-|dV|/v|:','HorizontalAlignment','right'); - txtDtErr = labkit.ui.view.form(gc, 'readonly'); - txtDtErr.Layout.Row = 7; txtDtErr.Layout.Column = 2; - - lblStatus = uilabel(gc,'Text','Ready'); - lblStatus.Layout.Row = 8; lblStatus.Layout.Column = [1 2]; - lblStatus.FontWeight = 'bold'; - - % -------- Log -------- - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'GUI started.'}); - txtLog = logUi.textArea; - txtLog.Value = {'GUI started.'}; - - % -------- Top/bottom controls -------- - topPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); - bottomPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); - plotControls = labkit.ui.view.panel( ... - ui.topControlsPanel, ... - 'topBottomPlotControls', ... - ui.bottomControlsPanel, ... - {'(none)'}, ... - {'(none)'}, ... - topPlotDefaults, ... - bottomPlotDefaults, ... - @(~,~) refreshPlotsOnly()); - ddTopX = plotControls.topX; - ddTopY = plotControls.topY; - cbTopGrid = plotControls.topGridCheckbox; - ddBotX = plotControls.bottomX; - ddBotY = plotControls.bottomY; - cbBotGrid = plotControls.bottomGridCheckbox; - axTop = ui.topAxes; - axBottom = ui.bottomAxes; - title(axTop,'Top Plot'); - xlabel(axTop,'X'); - ylabel(axTop,'Y'); - title(axBottom,'Bottom Plot'); - xlabel(axBottom,'X'); - ylabel(axBottom,'Y'); - - plotControls.topGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; - cbTopHold = uicheckbox(plotControls.topGrid,'Text','Hold','Value',false); - cbTopHold.Layout.Row = 1; cbTopHold.Layout.Column = 6; - cbTopTrim = uicheckbox(plotControls.topGrid,'Text','Show Trim','Value',true, ... - 'ValueChangedFcn',@(~,~) refreshCompare()); - cbTopTrim.Layout.Row = 1; cbTopTrim.Layout.Column = 7; - - plotControls.bottomGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; - cbBotHold = uicheckbox(plotControls.bottomGrid,'Text','Hold','Value',false); - cbBotHold.Layout.Row = 1; cbBotHold.Layout.Column = 6; - cbBotTrim = uicheckbox(plotControls.bottomGrid,'Text','Show Trim','Value',true, ... - 'ValueChangedFcn',@(~,~) refreshCompare()); - cbBotTrim.Layout.Row = 1; cbBotTrim.Layout.Column = 7; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('CSC debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - if isLoadDiagnostics - cleanup = onCleanup(@() delete(fig)); - addFiles({testLoadFile}); - drawnow; - varargout{1} = collectLoadDiagnostics(); - return; - end + fig = runCSCApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - %% App callbacks, loading, refresh, and plotting - function onOpenFiles(~,~) - [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... - 'Select Gamry DTA file(s)','MultiSelect','on'); - if isequal(files,0) - addLog('Open file canceled.'); - return; - end - if ischar(files) || isstring(files) - files = {char(files)}; - end - filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); - addFiles(filepaths); - end - - function onOpenFolder(~,~) - folder = uigetdir(pwd,'Select folder containing DTA files'); - if isequal(folder,0) - addLog('Folder selection canceled.'); - return; - end - filepaths = labkit.dta.findFiles(folder); - if isempty(filepaths) - uialert(fig,'No .DTA files found in the selected folder.','Open folder'); - addLog(['No .DTA files found under: ' folder]); - return; - end - addFiles(filepaths); - end - - function addFiles(filepaths) - if isempty(filepaths) - return; - end - - callbacks = struct(); - callbacks.onAdded = @(~, item) onAddedItem(item); - callbacks.onSkipped = @(filepath) addLog(['Skipped duplicate: ' filepath]); - callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed to load %s: %s', filepath, message)); - [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "cvct", callbacks); - S.items = S.session.items; - if ~isempty(S.items) && isempty(S.current) - S.current = 1; - end - refreshFileList(); - loadCurrentItem(); - - if ~isempty(report.failed) - firstError = report.failed(1); - uialert(fig, sprintf('Failed to load:\n%s\n\n%s', ... - firstError.filepath, firstError.message), 'Load error'); - end - end - - function onAddedItem(item) - for i = 1:numel(item.logmsg) - addLog(item.logmsg{i}); - end - addLog(['Loaded: ' item.filepath]); - end - - function onSelectFile() - if isempty(S.items) || isempty(lbFiles.Value) - return; - end - idx = find(strcmp({S.items.name}, lbFiles.Value), 1); - if isempty(idx) - idx = 1; - end - S.current = idx; - loadCurrentItem(); - end - - function clearAllFiles() - S.session = labkit.dta.makeSession('cv_csc'); - S.items = S.session.items; - S.current = []; - clearCurrentItem(); - refreshFileList(); - clearBothAxes(); - addLog('Cleared all files.'); - end - - function reloadSelectedFile() - if isempty(S.items) || isempty(S.current) - uialert(fig,'No file selected.','Reload'); - addLog('Reload failed: no file selected.'); - return; - end - filepath = S.items(S.current).filepath; - [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, {S.items(S.current).name}, struct()); - S.items = S.session.items; - S.current = []; - addFiles({filepath}); - end - - function refreshFileList() - if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); - txtLoaded.Value = 'No files loaded'; - return; - end - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', {S.items.name}, S.current); - S.current = idx(1); - txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); - end - - function loadCurrentItem() - if isempty(S.items) - clearCurrentItem(); - return; - end - if isempty(S.current) || S.current < 1 || S.current > numel(S.items) - S.current = 1; - end - S.session.items(S.current).currentCurve = 1; - S.session.items(S.current).analysis = []; - S.items = S.session.items; - item = S.items(S.current); - S.filepath = item.filepath; - S.scanRate = item.scanRate; - S.curves = item.curves; - S.currentCurve = 1; - txtFile.Value = item.filepath; - - if isnan(S.scanRate) - txtScan.Value = 'Not found'; - else - txtScan.Value = sprintf('%.6f V/s (%.3f mV/s)', S.scanRate, S.scanRate*1000); - end - - if isempty(S.curves) - ddCurve.Items = {'(none)'}; - ddCurve.Value = '(none)'; - lblStatus.Text = 'No curve found'; - addLog('No curve parsed.'); - return; - end - - items = cell(1,numel(S.curves)); - for k = 1:numel(S.curves) - items{k} = sprintf('%s (%d rows)', S.curves(k).name, size(S.curves(k).data,1)); - end - ddCurve.Items = items; - ddCurve.Value = items{1}; - - lblStatus.Text = sprintf('Loaded %d curve(s)', numel(S.curves)); - addLog(sprintf('Loaded %d curve(s) from %s.', numel(S.curves), item.name)); - - updateDropdowns(); - autoSetDefaults(); - refreshAll(); - end - - function clearCurrentItem() - S.filepath = ''; - S.scanRate = NaN; - S.curves = struct('name',{},'headers',{},'units',{},'data',{},'numericMask',{}); - S.currentCurve = 1; - txtFile.Value = ''; - txtScan.Value = ''; - ddCurve.Items = {'(none)'}; - ddCurve.Value = '(none)'; - lblStatus.Text = 'Ready'; - txtQct.Value = ''; - txtQcv.Value = ''; - txtDiff.Value = ''; - txtRel.Value = ''; - txtDtErr.Value = ''; - end - - function onCurveChanged() - if isempty(S.curves) - return; - end - idx = find(strcmp(ddCurve.Items, ddCurve.Value),1); - if isempty(idx), idx = 1; end - S.currentCurve = idx; - syncSessionCurrentCurve(); - addLog(sprintf('Selected curve %d', idx)); - updateDropdowns(); - autoSetDefaults(); - refreshAll(); - end - - function autoPresetAndRefresh() - autoSetDefaults(); - refreshAll(); - end - - function onSwapPlots() - tx = ddTopX.Value; ty = ddTopY.Value; - bx = ddBotX.Value; by = ddBotY.Value; - - if any(strcmp(ddTopX.Items,bx)), ddTopX.Value = bx; end - if any(strcmp(ddTopY.Items,by)), ddTopY.Value = by; end - if any(strcmp(ddBotX.Items,tx)), ddBotX.Value = tx; end - if any(strcmp(ddBotY.Items,ty)), ddBotY.Value = ty; end - - addLog('Swapped top/bottom selections.'); - refreshPlotsOnly(); - refreshCompare(); - end - - function clearBothAxes() - cla(axTop); - cla(axBottom); - title(axTop,'Top Plot'); xlabel(axTop,'X'); ylabel(axTop,'Y'); - title(axBottom,'Bottom Plot'); xlabel(axBottom,'X'); ylabel(axBottom,'Y'); - addLog('Cleared both axes.'); - end - - function syncSessionCurrentCurve() - if ~isempty(S.session.items) && ~isempty(S.current) - S.session.items(S.current).currentCurve = S.currentCurve; - S.items = S.session.items; - end - end - - function updateDropdowns() - if isempty(S.curves), return; end - c = S.curves(S.currentCurve); - cols = c.headers(c.numericMask); - if isempty(cols) - cols = {'(none)'}; - end - ddTopX.Items = cols; - ddTopY.Items = cols; - ddBotX.Items = cols; - ddBotY.Items = cols; - addLog(['Numeric columns: ' strjoin(cols, ', ')]); - end - - function autoSetDefaults() - if isempty(S.curves), return; end - setDropdownValueIfExists(ddTopX,'Vf'); - setDropdownValueIfExists(ddTopY,'Im'); - setDropdownValueIfExists(ddBotX,'T'); - setDropdownValueIfExists(ddBotY,'Im'); - end - - function refreshPlotsOnly() - if isempty(S.curves), return; end - plotTop(); - plotBottom(); - end - - function refreshAll() - refreshPlotsOnly(); - refreshCompare(); - end - - function plotTop() - if isempty(S.curves), return; end - c = S.curves(S.currentCurve); - opts = struct('holdPlot', cbTopHold.Value, 'showGrid', cbTopGrid.Value, 'lineWidth', 1.2); - [x, y, xName, yName] = labkit.dta.getCurveXY(c, ddTopX.Value, ddTopY.Value); - labels = struct('title', c.name, 'x', xName, 'y', yName); - info = labkit.ui.view.draw(axTop, 'xy', x, y, labels, opts); - if ~info.ok - addLog('Top plot skipped: invalid X/Y.'); - return; - end - addLog(sprintf('Top plot: %s vs %s, n=%d', info.yName, info.xName, numel(info.x))); - end - - function plotBottom() - if isempty(S.curves), return; end - c = S.curves(S.currentCurve); - opts = struct('holdPlot', cbBotHold.Value, 'showGrid', cbBotGrid.Value, 'lineWidth', 1.2); - [x, y, xName, yName] = labkit.dta.getCurveXY(c, ddBotX.Value, ddBotY.Value); - labels = struct('title', c.name, 'x', xName, 'y', yName); - info = labkit.ui.view.draw(axBottom, 'xy', x, y, labels, opts); - if ~info.ok - addLog('Bottom plot skipped: invalid X/Y.'); - return; - end - addLog(sprintf('Bottom plot: %s vs %s, n=%d', info.yName, info.xName, numel(info.x))); - end - - function refreshCompare() - if isempty(S.curves) - txtQct.Value = ''; - txtQcv.Value = ''; - txtDiff.Value = ''; - txtRel.Value = ''; - txtDtErr.Value = ''; - return; - end - - c = S.curves(S.currentCurve); - opts = struct(); - opts.mode = ddMode.Value; - opts.scanRate = S.scanRate; - opts.area_cm2 = edArea.Value; - R = computeCSC(c, opts); - - if ~R.ok - txtQct.Value = R.message; - txtQcv.Value = R.message; - txtDiff.Value = '-'; - txtRel.Value = '-'; - txtDtErr.Value = '-'; - if isfield(R, 'logMessage') && ~isempty(R.logMessage) - addLog(R.logMessage); - end - return; - end - - txtQct.Value = formatChargeAndCSC(R.Qct, R.area_cm2); - txtQcv.Value = formatChargeAndCSC(R.Qcv, R.area_cm2); - txtDiff.Value = formatChargeAndCSC(R.diff_C, R.area_cm2); - txtRel.Value = sprintf('%.6f %%', R.rel_pct); - txtDtErr.Value = sprintf('%.6e s', R.dtErr); - - clearTrim(axTop); - clearTrim(axBottom); - - if cbTopTrim.Value && strcmp(ddTopY.Value,'Im') - [xTop, ~, ~, ~] = labkit.dta.getCurveXY(c, ddTopX.Value, ddTopY.Value); - if numel(xTop) == numel(R.IcathDisp) - hold(axTop,'on'); - plot(axTop, xTop, R.IcathDisp, 'Color',[0.1 0.6 0.1], ... - 'LineWidth',1.0,'Tag','trimCath'); - plot(axTop, xTop, R.IanodDisp, 'Color',[0.8 0.3 0.1], ... - 'LineWidth',1.0,'Tag','trimAnod'); - hold(axTop,'off'); - end - end - - if cbBotTrim.Value && strcmp(ddBotY.Value,'Im') - [xBot, ~, ~, ~] = labkit.dta.getCurveXY(c, ddBotX.Value, ddBotY.Value); - if numel(xBot) == numel(R.IcathDisp) - hold(axBottom,'on'); - plot(axBottom, xBot, R.IcathDisp, 'Color',[0.1 0.6 0.1], ... - 'LineWidth',1.0,'Tag','trimCath'); - plot(axBottom, xBot, R.IanodDisp, 'Color',[0.8 0.3 0.1], ... - 'LineWidth',1.0,'Tag','trimAnod'); - hold(axBottom,'off'); - end - end - - addLog(sprintf(['Compare [%s]: Qct=%.6e C, Qcv=%.6e C, ', ... - 'rel=%.6f %%, maxdt=%.3e s'], ... - ddMode.Value, R.Qct, R.Qcv, R.rel_pct, R.dtErr)); - - if isnan(R.area_cm2) - lblStatus.Text = 'Charge shown (area not set)'; - else - lblStatus.Text = sprintf('CSC normalized by %.6g cm^2', R.area_cm2); - end - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end - - function diagnostics = collectLoadDiagnostics() - diagnostics = struct(); - diagnostics.file = txtFile.Value; - diagnostics.scanRate = txtScan.Value; - diagnostics.curveItems = ddCurve.Items; - diagnostics.topLineCount = numel(findobj(axTop, 'Type', 'Line')); - diagnostics.bottomLineCount = numel(findobj(axBottom, 'Type', 'Line')); - diagnostics.qct = txtQct.Value; - diagnostics.qcv = txtQcv.Value; - diagnostics.status = lblStatus.Text; - diagnostics.log = txtLog.Value; - end -end - -%% App-local formatting and plot cleanup - -function s = formatChargeAndCSC(Q, area_cm2) - if isnan(area_cm2) || area_cm2 <= 0 - s = sprintf('%.12e C', Q); - else - CSC_mC_cm2 = 1e3 * Q / area_cm2; % C -> mC/cm^2 - s = sprintf('%.12e C | %.12e mC/cm^2', Q, CSC_mC_cm2); - end -end - -function clearTrim(ax) - delete(findobj(ax,'Tag','trimCath')); - delete(findobj(ax,'Tag','trimAnod')); -end - -function setDropdownValueIfExists(dd, valueText) - if any(strcmp(dd.Items, valueText)) - dd.Value = valueText; - elseif ~isempty(dd.Items) - dd.Value = dd.Items{1}; - end -end - -function handlers = cscAppTestHandlers() - handlers = struct( ... - 'command', {'computeCSC'}, ... - 'minArgs', {2}, ... - 'maxArgs', {2}, ... - 'maxOutputs', {1}, ... - 'run', {@runComputeCSC}); -end - -function outputs = runComputeCSC(args) - outputs = {computeCSC(args{1}, args{2})}; -end - -function [filepath, tf] = parseCSCLoadDiagnosticsRequest(args) - filepath = ''; - tf = false; - if numel(args) < 2 ... - || ~(ischar(args{1}) || (isstring(args{1}) && isscalar(args{1}))) ... - || ~strcmp(string(args{1}), "__labkit_test__") ... - || ~(ischar(args{2}) || (isstring(args{2}) && isscalar(args{2}))) ... - || ~strcmp(string(args{2}), "loadFileDiagnostics") - return; - end - if numel(args) ~= 3 || ~(ischar(args{3}) || (isstring(args{3}) && isscalar(args{3}))) - error('labkit_CSC_app:InvalidTestArguments', ... - 'Command loadFileDiagnostics expects one filepath argument.'); - end - filepath = char(args{3}); - tf = true; -end - -%% App-local analysis -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/labkit_ChronoOverlay_app.m b/apps/electrochem/labkit_ChronoOverlay_app.m index 9636be5..48af2ff 100644 --- a/apps/electrochem/labkit_ChronoOverlay_app.m +++ b/apps/electrochem/labkit_ChronoOverlay_app.m @@ -3,7 +3,7 @@ % Single-file app that composes +labkit GUI/DTA APIs and owns overlay workflow choices. [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_ChronoOverlay_app', varargin, nargout, chronoOverlayAppTestHandlers()); + 'labkit_ChronoOverlay_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -17,539 +17,11 @@ error('labkit_ChronoOverlay_app:TooManyOutputs', 'labkit_ChronoOverlay_app returns at most the app figure handle.'); end - S = struct(); - S.session = labkit.dta.makeSession('chrono_overlay'); - S.items = S.session.items; - - workbenchOpts = struct(); - workbenchOpts.rightTitle = 'Overlay Plots'; - workbenchOpts.rightGridSize = [2 1]; - workbenchOpts.rightRowHeight = {'1x', '1x'}; - workbenchOpts.rightRowSpacing = 10; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry Multi-DTA Plot Export GUI', ... - 'position', [80 60 1480 900], ... - 'leftWidth', 340, ... - 'options', workbenchOpts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - right = ui.rightGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onRemoveSelected = @onRemoveSelected; - fileCallbacks.onClearAll = @onClearAll; - fileCallbacks.onExport = @onExportCSV; - fileCallbacks.onSelectFile = @(~,~) refreshPlots(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'removeSelected', 'Remove selected', ... - 'clearAll', 'Clear all', ... - 'export', 'Export curves CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... - struct('showRemoveSelected', true, 'multiselect', 'on')); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - plotOptionsUi = labkit.ui.view.panel(layFA, 'plotOptions', 4, 2); - gp = plotOptionsUi.grid; - - [~, ddXAxis] = labkit.ui.view.form(gp, 'dropdown', 'X axis:', ... - 'Items', {'Time (s)', 'Time (ms)', 'Sample #'}, ... - 'Value', 'Time (s)', ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - - [~, edLineWidth] = labkit.ui.view.form(gp, 'spinner', 'Line width:', ... - 'Value', 1.3, ... - 'Limits', [0.1 10], ... - 'Step', 0.1, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - - cbLegend = uicheckbox(gp, ... - 'Text', 'Show file-name legend', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - cbLegend.Layout.Row = 3; - cbLegend.Layout.Column = [1 2]; - - cbGrid = uicheckbox(gp, ... - 'Text', 'Show grid', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - cbGrid.Layout.Row = 4; - cbGrid.Layout.Column = [1 2]; - - infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... - 'Usage:', ... - '1. Open multiple .DTA files.', ... - '2. Curves are aligned to the center of the blank time between cathodic and anodic phases.', ... - '3. Voltage and current curves will be overlaid.', ... - '4. Export CSV columns as: TimeGapCenterAligned_s, V_*, I_*.', ... - '5. If files have different time grids, export uses a merged aligned-time axis with interpolation.' ... - }); - txtInfo = infoUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('Chrono overlay debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - axV = labkit.ui.view.axes(right, 1, 'Voltage', 'Time (s)', 'Vf (V)'); - axI = labkit.ui.view.axes(right, 2, 'Current', 'Time (s)', 'Im (A)'); + fig = runChronoOverlayApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - %% App callbacks, session actions, refresh, and export - function onOpenFiles(~, ~) - [f, p] = uigetfile( ... - {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - 'Select one or more Gamry DTA files', ... - 'MultiSelect', 'on'); - if isequal(f, 0) - addLog('Open cancelled.'); - return; - end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadFiles(filepaths); - end - - function onOpenFolder(~, ~) - folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); - if isequal(folder, 0) - addLog('Folder selection cancelled.'); - return; - end - - filepaths = labkit.dta.findFiles(folder); - if isempty(filepaths) - addLog(sprintf('No DTA files found under: %s', folder)); - uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); - return; - end - - addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); - loadFiles(filepaths); - end - - function loadFiles(filepaths) - if isempty(filepaths) - return; - end - - callbacks = struct(); - callbacks.onAdded = @(~, ~) []; - callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); - callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); - [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); - postProcessAddedItems(report.added); - S.items = S.session.items; - - refreshFileList(); - refreshPlots(); - - if ~isempty(report.failed) - firstError = report.failed(1); - uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); - end - end - - function postProcessAddedItems(filepaths) - for iFile = 1:numel(filepaths) - idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); - if isempty(idx) - continue; - end - - item = S.session.items(idx); - [item, alignMsg] = alignByPulseGap(item); - S.session.items(idx) = item; - addLog(alignMsg); - - for ii = 1:numel(item.logmsg) - addLog(item.logmsg{ii}); - end - addLog(sprintf('%s: %s', item.name, item.message)); - addLog(sprintf('Loaded: %s', filepaths{iFile})); - end - end - - function onRemoveSelected(~, ~) - if isempty(S.items) || isempty(lbFiles.Value) - return; - end - callbacks = struct(); - callbacks.onRemoved = @(name, ~) addLog(sprintf('Removed: %s', name)); - [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, lbFiles.Value, callbacks); - S.items = S.session.items; - refreshFileList(); - refreshPlots(); - end - - function onClearAll(~, ~) - S.session = labkit.dta.makeSession('chrono_overlay'); - S.items = S.session.items; - refreshFileList(); - refreshPlots(); - addLog('Cleared all files.'); - end - - function refreshFileList() - if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listItems', {}); - txtLoaded.Value = 'No files loaded'; - return; - end - labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); - txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); - end - - function refreshPlots() - if isempty(S.items) - plotVTIT(axV, axI, struct([]), plotOptions()); - return; - end - - items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); - if isempty(items) - cla(axV); - cla(axI); - return; - end - - plotVTIT(axV, axI, items, plotOptions()); - end - - function onExportCSV(~, ~) - if isempty(S.items) - uialert(fig, 'No files loaded.', 'Export'); - return; - end - - items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); - if isempty(items) - uialert(fig, 'No files selected for export.', 'Export'); - return; - end - - [f, p] = uiputfile('gamry_overlay_curves.csv', 'Save overlay curves CSV'); - if isequal(f, 0) - return; - end - - T = buildOverlayExportTable(items); - out = fullfile(p, f); - writetable(T, out); - addLog(sprintf('Exported CSV: %s', out)); - end - - function opts = plotOptions() - opts = struct(); - opts.xAxis = ddXAxis.Value; - opts.lineWidth = edLineWidth.Value; - opts.showGrid = cbGrid.Value; - opts.showLegend = cbLegend.Value; - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end -end - -function handlers = chronoOverlayAppTestHandlers() - handlers = struct( ... - 'command', {'alignByPulseGap', 'buildOverlayExportTable'}, ... - 'minArgs', {1, 1}, ... - 'maxArgs', {1, 1}, ... - 'maxOutputs', {2, 1}, ... - 'run', {@runAlignByPulseGap, @runBuildOverlayExportTable}); -end - -function outputs = runAlignByPulseGap(args) - [item, msg] = alignByPulseGap(args{1}); - outputs = {item, msg}; -end - -function outputs = runBuildOverlayExportTable(args) - outputs = {buildOverlayExportTable(args{1})}; -end - -%% App-local analysis -function [item, msg] = alignByPulseGap(item) - t = chronoTime(item); - if isempty(t) - error('Chrono item has no time vector.'); - end - - pulseMsg = ''; - if isfield(item, 'pulseMessage') - pulseMsg = item.pulseMessage; - elseif isfield(item, 'pulse') && isfield(item.pulse, 'message') - pulseMsg = item.pulse.message; - end - - pulse = emptyPulse(); - if isfield(item, 'pulse') - pulse = item.pulse; - end - - if isfield(item, 'name') - itemName = item.name; - else - itemName = ''; - end - - if isfield(pulse, 'ok') && pulse.ok - alignTime = 0.5 * (pulse.gap_start + pulse.gap_end); - if isfinite(alignTime) - item.alignTime = alignTime; - item.tAligned = t - alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: aligned to cathodic/anodic blank center at %.9g s (gap %.9g to %.9g s, %s).', ... - itemName, alignTime, pulse.gap_start, pulse.gap_end, pulse.method); - return; - end - - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: blank center not found, fallback to first sample (%s).', itemName, pulseMsg); - return; - end - - item.alignTime = t(1); - item.tAligned = t - item.alignTime; - item.alignTime_s = item.alignTime; - item.tAligned_s = item.tAligned; - msg = sprintf('%s: pulse gap not found, fallback to first sample (%s).', itemName, pulseMsg); -end - -%% App-local export -function T = buildOverlayExportTable(items) - timeUnion = []; - for i = 1:numel(items) - timeUnion = [timeUnion; chronoAlignedTime(items(i))]; %#ok - end - timeUnion = unique(timeUnion); - timeUnion = sort(timeUnion); - - T = table(timeUnion, 'VariableNames', {'TimeGapCenterAligned_s'}); - for i = 1:numel(items) - safeName = sanitizeFieldName(items(i).name); - vName = ['V_' safeName]; - iName = ['I_' safeName]; - - tAligned = chronoAlignedTime(items(i)); - Vf = chronoVoltage(items(i)); - Im = chronoCurrent(items(i)); - if numel(tAligned) >= 2 - vData = interp1(tAligned, Vf, timeUnion, 'linear', NaN); - iData = interp1(tAligned, Im, timeUnion, 'linear', NaN); - else - vData = NaN(size(timeUnion)); - iData = NaN(size(timeUnion)); - end - - T.(vName) = vData; - T.(iName) = iData; - end -end - -%% App-local plotting -function plotVTIT(axV, axI, items, opts) - if nargin < 4 - opts = struct(); - end - if ~isfield(opts, 'xAxis') - opts.xAxis = 'Time (s)'; - end - if ~isfield(opts, 'lineWidth') - opts.lineWidth = 1.3; - end - if ~isfield(opts, 'showGrid') - opts.showGrid = true; - end - if ~isfield(opts, 'showLegend') - opts.showLegend = true; - end - - cla(axV); - cla(axI); - - if isempty(items) - title(axV, 'Voltage'); - title(axI, 'Current'); - xlabel(axV, 'Blank-Center Aligned Time (s)'); - xlabel(axI, 'Blank-Center Aligned Time (s)'); - ylabel(axV, 'Vf (V)'); - ylabel(axI, 'Im (A)'); - return; - end - - cmap = lines(numel(items)); - hold(axV, 'on'); - hold(axI, 'on'); - - labels = cell(1, numel(items)); - for k = 1:numel(items) - item = items(k); - x = chooseX(item, opts.xAxis); - plot(axV, x, chronoVoltage(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); - plot(axI, x, chronoCurrent(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); - labels{k} = char(item.name); - end - - hold(axV, 'off'); - hold(axI, 'off'); - - xlabelText = axisLabel(opts.xAxis); - xlabel(axV, xlabelText); - xlabel(axI, xlabelText); - ylabel(axV, 'Vf (V)'); - ylabel(axI, 'Im (A)'); - title(axV, sprintf('Voltage Overlay (%d file%s)', numel(items), pluralS(numel(items)))); - title(axI, sprintf('Current Overlay (%d file%s)', numel(items), pluralS(numel(items)))); - - if opts.showGrid - grid(axV, 'on'); - grid(axI, 'on'); - else - grid(axV, 'off'); - grid(axI, 'off'); - end - - if opts.showLegend - legend(axV, labels, 'Interpreter', 'none', 'Location', 'best'); - legend(axI, labels, 'Interpreter', 'none', 'Location', 'best'); - else - legend(axV, 'off'); - legend(axI, 'off'); - end -end - -%% Small app-local utilities -function t = chronoTime(item) - if isfield(item, 't') && ~isempty(item.t) - t = item.t; - elseif isfield(item, 't_s') && ~isempty(item.t_s) - t = item.t_s; - else - t = []; - end - t = t(:); -end - -function t = chronoAlignedTime(item) - if isfield(item, 'tAligned') && ~isempty(item.tAligned) - t = item.tAligned(:); - elseif isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) - t = item.tAligned_s(:); - else - t = []; - end -end - -function v = chronoVoltage(item) - if isfield(item, 'Vf') && ~isempty(item.Vf) - v = item.Vf(:); - elseif isfield(item, 'Vf_V') && ~isempty(item.Vf_V) - v = item.Vf_V(:); - else - v = []; - end -end - -function i = chronoCurrent(item) - if isfield(item, 'Im') && ~isempty(item.Im) - i = item.Im(:); - elseif isfield(item, 'Im_A') && ~isempty(item.Im_A) - i = item.Im_A(:); - else - i = []; - end -end - -function x = chooseX(item, mode) - switch mode - case 'Time (ms)' - x = 1e3 * chronoAlignedTime(item); - case 'Sample #' - x = samplePoint(item); - otherwise - x = chronoAlignedTime(item); - end -end - -function pt = samplePoint(item) - if isfield(item, 'pt') && ~isempty(item.pt) - pt = item.pt(:); - else - pt = (0:numel(chronoAlignedTime(item))-1).'; - end -end - -function txt = axisLabel(mode) - switch mode - case 'Time (ms)' - txt = 'Blank-Center Aligned Time (ms)'; - case 'Sample #' - txt = 'Sample #'; - otherwise - txt = 'Blank-Center Aligned Time (s)'; - end -end - -function s = pluralS(n) - if n == 1 - s = ''; - else - s = 's'; - end -end - -function out = sanitizeFieldName(txt) - out = matlab.lang.makeValidName(txt); -end - -function pulse = emptyPulse() - pulse = struct( ... - 'ok', false, ... - 'method', '-', ... - 'message', '', ... - 'cath_start', NaN, ... - 'cath_end', NaN, ... - 'anod_start', NaN, ... - 'anod_end', NaN, ... - 'Ic_nominal', NaN, ... - 'Ia_nominal', NaN, ... - 'pre_start', NaN, ... - 'pre_end', NaN, ... - 'gap_start', NaN, ... - 'gap_end', NaN, ... - 'post_start', NaN, ... - 'post_end', NaN); - - pulse.cath = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); - pulse.gap = struct('start_s', NaN, 'end_s', NaN, 'center_s', NaN); end diff --git a/apps/electrochem/labkit_EIS_app.m b/apps/electrochem/labkit_EIS_app.m index 68d7c4e..ebdab79 100644 --- a/apps/electrochem/labkit_EIS_app.m +++ b/apps/electrochem/labkit_EIS_app.m @@ -3,7 +3,7 @@ % Single-file app that composes +labkit GUI/DTA APIs and owns EIS workflow choices. [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_EIS_app', varargin, nargout, eisAppTestHandlers()); + 'labkit_EIS_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -17,529 +17,11 @@ error('labkit_EIS_app:TooManyOutputs', 'labkit_EIS_app returns at most the app figure handle.'); end - S = struct(); - S.session = labkit.dta.makeSession('eis_overlay'); - S.items = S.session.items; - - axisItems = { ... - 'Freq (Hz)', ... - 'log10(Freq)', ... - 'Time (s)', ... - 'Point #', ... - 'Zreal (ohm)', ... - 'Zimag (ohm)', ... - '-Zimag (ohm)', ... - 'Zmod (ohm)', ... - 'Zphz (deg)', ... - 'Idc (A)', ... - 'Vdc (V)'}; - - workbenchOpts = struct(); - workbenchOpts.rightTitle = 'Plot'; - workbenchOpts.rightGridSize = [1 1]; - workbenchOpts.rightRowHeight = {'1x'}; - workbenchOpts.rightRowSpacing = 8; - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry EIS Multi-DTA Plot GUI', ... - 'position', [80 60 1500 900], ... - 'leftWidth', 360, ... - 'options', workbenchOpts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - right = ui.rightGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onRemoveSelected = @onRemoveSelected; - fileCallbacks.onClearAll = @onClearAll; - fileCallbacks.onExport = @onExportCSV; - fileCallbacks.onSelectFile = @(~,~) refreshPlot(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'removeSelected', 'Remove selected', ... - 'clearAll', 'Clear all', ... - 'export', 'Export current plot CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... - struct('showRemoveSelected', true, 'multiselect', 'on')); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - plotOptionsUi = labkit.ui.view.panel(layFA, 'plotOptions', 8, 2); - gp = plotOptionsUi.grid; - - [~, ddX] = labkit.ui.view.form(gp, 'dropdown', 'X axis:', ... - 'Items', axisItems, ... - 'Value', 'Zreal (ohm)', ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - [~, ddY] = labkit.ui.view.form(gp, 'dropdown', 'Y axis:', ... - 'Items', axisItems, ... - 'Value', '-Zimag (ohm)', ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - [~, edLineWidth] = labkit.ui.view.form(gp, 'spinner', 'Line width:', ... - 'Value', 1.4, ... - 'Limits', [0.1 10], ... - 'Step', 0.1, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - [~, edMarkerSize] = labkit.ui.view.form(gp, 'spinner', 'Marker size:', ... - 'Value', 6, ... - 'Limits', [1 20], ... - 'Step', 1, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - cbMarkers = uicheckbox(gp, ... - 'Text', 'Show markers', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbMarkers.Layout.Row = 5; - cbMarkers.Layout.Column = [1 2]; - - cbLogX = uicheckbox(gp, ... - 'Text', 'Log X', ... - 'Value', false, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbLogX.Layout.Row = 6; - cbLogX.Layout.Column = [1 2]; - - cbLogY = uicheckbox(gp, ... - 'Text', 'Log Y', ... - 'Value', false, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbLogY.Layout.Row = 7; - cbLogY.Layout.Column = [1 2]; - - row8 = uigridlayout(gp, [1 2]); - row8.Layout.Row = 8; - row8.Layout.Column = [1 2]; - row8.ColumnWidth = {'1x', '1x'}; - row8.RowHeight = {'fit'}; - row8.Padding = [0 0 0 0]; - row8.ColumnSpacing = 8; - - cbLegend = uicheckbox(row8, ... - 'Text', 'Legend', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - cbGrid = uicheckbox(row8, ... - 'Text', 'Grid', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshPlot()); - - infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... - 'Usage:', ... - '1. Open one or more EIS .DTA files containing ZCURVE.', ... - '2. Choose any X and Y axis combination.', ... - '3. Use Zreal vs -Zimag for a Nyquist plot.', ... - '4. Use Freq vs Zmod or Zphz for Bode-style plots.', ... - '5. CSV export writes one shared row index with X/Y pairs per file.'}); - txtInfo = infoUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - - ax = labkit.ui.view.axes(right, 1, 'EIS Overlay', 'Zreal (ohm)', '-Zimag (ohm)'); - - txtSummary = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtSummary, laySR, 2); - txtSummary.Value = {'No files loaded.'}; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('EIS debug trace enabled.'); - debugLog.instrumentFigure(fig); - end + fig = runEISApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - %% App callbacks, session actions, refresh, and export - function onOpenFiles(~, ~) - [f, p] = uigetfile( ... - {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... - 'Select one or more Gamry EIS DTA files', ... - 'MultiSelect', 'on'); - if isequal(f, 0) - addLog('Open cancelled.'); - return; - end - - if ischar(f) || isstring(f) - f = {char(f)}; - end - - filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); - loadFiles(filepaths); - end - - function onOpenFolder(~, ~) - folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); - if isequal(folder, 0) - addLog('Folder selection cancelled.'); - return; - end - - filepaths = labkit.dta.findFiles(folder); - if isempty(filepaths) - addLog(sprintf('No DTA files found under: %s', folder)); - uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); - return; - end - - addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); - loadFiles(filepaths); - end - - function loadFiles(filepaths) - if isempty(filepaths) - return; - end - - callbacks = struct(); - callbacks.onAdded = @onAddedDTA; - callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); - callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); - [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "eis", callbacks); - S.items = S.session.items; - - refreshFileList(); - refreshPlot(); - - if ~isempty(report.failed) - firstError = report.failed(1); - uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); - end - end - - function onAddedDTA(filepath, item) - for ii = 1:numel(item.logmsg) - addLog(item.logmsg{ii}); - end - addLog(sprintf('%s: %s', item.name, item.message)); - addLog(sprintf('Loaded: %s', filepath)); - end - - function onRemoveSelected(~, ~) - if isempty(S.items) || isempty(lbFiles.Value) - return; - end - callbacks = struct(); - callbacks.onRemoved = @(name, ~) addLog(sprintf('Removed: %s', name)); - [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, lbFiles.Value, callbacks); - S.items = S.session.items; - refreshFileList(); - refreshPlot(); - end - - function onClearAll(~, ~) - S.session = labkit.dta.makeSession('eis_overlay'); - S.items = S.session.items; - refreshFileList(); - refreshPlot(); - addLog('Cleared all files.'); - end - - function refreshFileList() - if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listItems', {}); - txtLoaded.Value = 'No files loaded'; - return; - end - labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); - txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); - end - - function refreshPlot() - cla(ax); - ax.XScale = ternary(cbLogX.Value, 'log', 'linear'); - ax.YScale = ternary(cbLogY.Value, 'log', 'linear'); - axis(ax, 'normal'); - - if isempty(S.items) - title(ax, 'EIS Overlay'); - xlabel(ax, labelForAxis(ddX.Value)); - ylabel(ax, labelForAxis(ddY.Value)); - txtSummary.Value = {'No files loaded.'}; - return; - end - - items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); - if isempty(items) - txtSummary.Value = {'No files selected.'}; - return; - end - - plotOpts = struct(); - plotOpts.xName = ddX.Value; - plotOpts.yName = ddY.Value; - plotOpts.logX = cbLogX.Value; - plotOpts.logY = cbLogY.Value; - plotOpts.lineWidth = edLineWidth.Value; - plotOpts.markerSize = edMarkerSize.Value; - plotOpts.showMarkers = cbMarkers.Value; - plotOpts.showLegend = cbLegend.Value; - plotOpts.showGrid = cbGrid.Value; - plotOverlay(ax, items, plotOpts); - - txtSummary.Value = buildSummary(items); - end - - function onExportCSV(~, ~) - items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); - if isempty(items) - uialert(fig, 'No files selected for export.', 'Export'); - return; - end - - [f, p] = uiputfile('gamry_eis_plot_export.csv', 'Save current X/Y plot CSV'); - if isequal(f, 0) - return; - end - - T = buildExportTable(items, ddX.Value, ddY.Value, cbLogX.Value, cbLogY.Value); - out = fullfile(p, f); - writetable(T, out); - addLog(sprintf('Exported CSV: %s', out)); - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end -end - -%% App-local plotting and summary helpers -function handlers = eisAppTestHandlers() - handlers = struct( ... - 'command', {'buildExportTable', 'valuesForAxis'}, ... - 'minArgs', {5, 2}, ... - 'maxArgs', {5, 2}, ... - 'maxOutputs', {1, 1}, ... - 'run', {@runBuildExportTable, @runValuesForAxis}); -end - -function outputs = runBuildExportTable(args) - outputs = {buildExportTable(args{1}, args{2}, args{3}, args{4}, args{5})}; -end - -function outputs = runValuesForAxis(args) - outputs = {valuesForAxis(args{1}, args{2})}; -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/labkit_VTResistance_app.m b/apps/electrochem/labkit_VTResistance_app.m index 364aae4..1c4c950 100644 --- a/apps/electrochem/labkit_VTResistance_app.m +++ b/apps/electrochem/labkit_VTResistance_app.m @@ -11,7 +11,7 @@ % - Compute baseline-corrected resistance as abs((Vss - Vbaseline) / Iss). [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_VTResistance_app', varargin, nargout, vtAppTestHandlers()); + 'labkit_VTResistance_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -25,1024 +25,11 @@ error('labkit_VTResistance_app:TooManyOutputs', 'labkit_VTResistance_app returns at most the app figure handle.'); end - S = struct(); - S.session = labkit.dta.makeSession('vt_resistance'); - S.items = S.session.items; - S.current = []; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'Gamry VT Steady Resistance GUI', ... - 'position', [40 30 1680 980], ... - 'leftWidth', 430, ... - 'options', struct('rightKind', 'dualPlot'))); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - fileCallbacks = struct(); - fileCallbacks.onOpenFiles = @onOpenFiles; - fileCallbacks.onOpenFolder = @onOpenFolder; - fileCallbacks.onClearAll = @(~,~) clearAllFiles(); - fileCallbacks.onExport = @(~,~) exportResultsCSV(); - fileCallbacks.onSelectFile = @(~,~) onSelectFile(); - fileLabels = struct( ... - 'panelTitle', 'Files', ... - 'openFiles', 'Open DTA file(s)', ... - 'openFolder', 'Open folder recursively', ... - 'clearAll', 'Clear all', ... - 'export', 'Export results CSV', ... - 'loadedText', 'No files loaded'); - fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); - lbFiles = fileUi.listbox; - txtLoaded = fileUi.loadedText; - - settingsUi = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [3 2]); - gs = settingsUi.grid; - - uilabel(gs,'Text','Pulse detection:','HorizontalAlignment','right'); - ddPulseMode = uidropdown(gs, ... - 'Items',{'Metadata first, then auto','Metadata only','Auto from Im only'}, ... - 'Value','Metadata first, then auto', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddPulseMode.Layout.Row = 1; - ddPulseMode.Layout.Column = 2; - - uilabel(gs,'Text','Steady window:','HorizontalAlignment','right'); - ddSteadyWindow = uidropdown(gs, ... - 'Items',{'Full pulse median','Center 60% median'}, ... - 'Value','Full pulse median', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddSteadyWindow.Layout.Row = 2; - ddSteadyWindow.Layout.Column = 2; - - uilabel(gs,'Text','Resistance voltage:','HorizontalAlignment','right'); - ddVoltageMode = uidropdown(gs, ... - 'Items',{'Baseline-corrected dV/I','Raw Vf/I'}, ... - 'Value','Baseline-corrected dV/I', ... - 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); - ddVoltageMode.Layout.Row = 3; - ddVoltageMode.Layout.Column = 2; - - actionUi = labkit.ui.view.section(layFA, 'Plot / Debug', 3, [2 3]); - ga = actionUi.grid; - - btnReanalyze = uibutton(ga,'Text','Re-analyze file','ButtonPushedFcn',@(~,~) analyzeCurrentFile()); - btnReanalyze.Layout.Row = 1; btnReanalyze.Layout.Column = 1; - btnRefresh = uibutton(ga,'Text','Refresh plots','ButtonPushedFcn',@(~,~) refreshPlots()); - btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 2; - btnSwap = uibutton(ga,'Text','Swap top / bottom','ButtonPushedFcn',@(~,~) swapPlots()); - btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 3; - - btnReset = uibutton(ga,'Text','Reset axes','ButtonPushedFcn',@(~,~) resetAxes()); - btnReset.Layout.Row = 2; btnReset.Layout.Column = 1; - cbShowMarkers = uicheckbox(ga,'Text','Show markers','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowMarkers.Layout.Row = 2; cbShowMarkers.Layout.Column = 2; - cbShowShading = uicheckbox(ga,'Text','Shade windows','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); - cbShowShading.Layout.Row = 2; cbShowShading.Layout.Column = 3; - - infoUi = labkit.ui.view.section(laySR, 'Current File Summary', 1, [13 2]); - gi = infoUi.grid; - - S.txtControlMode = labkit.ui.view.form(gi, 'info', 1, 'Control mode:'); - S.txtDetect = labkit.ui.view.form(gi, 'info', 2, 'Detection:'); - S.txtWindow = labkit.ui.view.form(gi, 'info', 3, 'Window:'); - S.txtCathIV = labkit.ui.view.form(gi, 'info', 4, 'Cathodic I / Vss:'); - S.txtAnodIV = labkit.ui.view.form(gi, 'info', 5, 'Anodic I / Vss:'); - S.txtCathBase = labkit.ui.view.form(gi, 'info', 6, 'Cathodic baseline:'); - S.txtAnodBase = labkit.ui.view.form(gi, 'info', 7, 'Anodic baseline:'); - S.txtCathBaseWin = labkit.ui.view.form(gi, 'info', 8, 'Cath baseline window:'); - S.txtAnodBaseWin = labkit.ui.view.form(gi, 'info', 9, 'Anod baseline window:'); - S.txtCathR = labkit.ui.view.form(gi, 'info', 10, 'Cathodic R:'); - S.txtAnodR = labkit.ui.view.form(gi, 'info', 11, 'Anodic R:'); - S.txtAvgR = labkit.ui.view.form(gi, 'info', 12, 'Average R:'); - S.txtStatus = labkit.ui.view.form(gi, 'info', 13, 'Status:'); - - tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, ... - {'File','Ic(A)','Ia(A)','Vc_ss(V)','Va_ss(V)','R_cath(ohm)','R_anod(ohm)','R_avg(ohm)','Detection'}, ... - cell(0,9)); - tbl = tableUi.table; - - logUi = labkit.ui.view.panel(layLog, 'log', 1); - txtLog = logUi.textArea; - - topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); - bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); - plotControls = labkit.ui.view.panel( ... - ui.topControlsPanel, ... - 'topBottomPlotControls', ... - ui.bottomControlsPanel, ... - {'Time (s)', 'Sample #'}, ... - {'VT: Vf vs time', 'IT: Im vs time'}, ... - topPlotDefaults, ... - bottomPlotDefaults, ... - @(~,~) refreshPlots()); - ddTopX = plotControls.topX; - ddTopY = plotControls.topY; - cbTopGrid = plotControls.topGridCheckbox; - axTop = ui.topAxes; - ddBotX = plotControls.bottomX; - ddBotY = plotControls.bottomY; - cbBotGrid = plotControls.bottomGridCheckbox; - axBottom = ui.bottomAxes; - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('VT resistance debug trace enabled.'); - debugLog.instrumentFigure(fig); - end + fig = runVTResistanceApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - %% App callbacks, session actions, refresh, plotting, and export - function onOpenFiles(~,~) - [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... - 'Select Gamry DTA file(s)','MultiSelect','on'); - if isequal(files,0) - return; - end - if ischar(files) - files = {files}; - end - filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); - addFiles(filepaths); - end - - function onOpenFolder(~,~) - folder = uigetdir(pwd,'Select folder containing DTA files'); - if isequal(folder,0) - return; - end - filepaths = labkit.dta.findFiles(folder); - if isempty(filepaths) - uialert(fig,'No .DTA files found in the selected folder.','Open folder'); - return; - end - addFiles(filepaths); - end - - function addFiles(filepaths) - callbacks = struct(); - callbacks.onAdded = @(~, ~) []; - callbacks.onSkipped = @(fp) addLog(['Skipped duplicate: ' fp]); - callbacks.onFailed = @(fp, msg) addLog(sprintf('Failed to load %s: %s', fp, msg)); - [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); - postProcessAddedItems(report.added); - S.items = S.session.items; - if ~isempty(S.items) && isempty(S.current) - S.current = 1; - end - refreshFileList(); - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - end - - function postProcessAddedItems(filepaths) - for iFile = 1:numel(filepaths) - idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); - if isempty(idx) - continue; - end - item = S.session.items(idx); - for ii = 1:numel(item.logmsg) - addLog(item.logmsg{ii}); - end - item = analyzeItem(item); - S.session.items(idx) = item; - addLog(['Loaded: ' filepaths{iFile}]); - end - end - - function analyzeCurrentFile() - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - refreshResultsSummary(); - refreshPlots(); - return; - end - S.items(S.current) = analyzeItem(S.items(S.current)); - S.session.items = S.items; - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - end - - function item = analyzeItem(item) - opts = struct(); - opts.windowMode = ddSteadyWindow.Value; - opts.voltageMode = ddVoltageMode.Value; - opts.pulseMode = ddPulseMode.Value; - - A = computeResistance(item, opts); - if A.ok - addLog(sprintf('%s: Rc=%.6g ohm, Ra=%.6g ohm, Ravg=%.6g ohm', ... - item.name, A.Rc_abs_ohm, A.Ra_abs_ohm, A.Ravg_abs_ohm)); - elseif isfield(A, 'logOnFailure') && A.logOnFailure - addLog(sprintf('%s: %s', item.name, A.message)); - end - item.analysis = A; - end - - function onSelectFile() - if isempty(lbFiles.Items) - S.current = []; - resetAxesToDefaultState(); - refreshResultsSummary(); - refreshPlots(); - return; - end - - idx = find(strcmp(lbFiles.Items, lbFiles.Value), 1); - if isempty(idx) - S.current = []; - else - S.current = idx; - end - - restoreDefaultPlotSelections(); - resetAxesToDefaultState(); - refreshResultsSummary(); - refreshPlots(); - end - - function clearAllFiles() - S.session = labkit.dta.makeSession('vt_resistance'); - S.items = S.session.items; - S.current = []; - restoreDefaultPlotSelections(); - resetAxesToDefaultState(); - refreshFileList(); - refreshBatchTable(); - refreshResultsSummary(); - refreshPlots(); - addLog('Cleared all files.'); - end - - function refreshFileList() - if isempty(S.items) - labkit.ui.view.update(lbFiles, 'listSelection', {}); - txtLoaded.Value = fileLabels.loadedText; - S.current = []; - return; - end - - names = {S.items.name}; - [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); - S.current = idx(1); - txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); - end - - function refreshBatchTable() - if isempty(S.items) - tbl.Data = cell(0,9); - return; - end - tbl.Data = buildBatchTableData(S.items); - end - - function refreshResultsSummary() - S.txtControlMode.Value = '-'; - S.txtDetect.Value = '-'; - S.txtWindow.Value = '-'; - S.txtCathIV.Value = '-'; - S.txtAnodIV.Value = '-'; - S.txtCathBase.Value = '-'; - S.txtAnodBase.Value = '-'; - S.txtCathBaseWin.Value = '-'; - S.txtAnodBaseWin.Value = '-'; - S.txtCathR.Value = '-'; - S.txtAnodR.Value = '-'; - S.txtAvgR.Value = '-'; - S.txtStatus.Value = '-'; - - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - return; - end - it = S.items(S.current); - S.txtControlMode.Value = chronoControlModeText(it); - if isempty(it.analysis) || ~it.analysis.ok - if ~isempty(it.analysis) && isfield(it.analysis,'message') - S.txtStatus.Value = it.analysis.message; - else - S.txtStatus.Value = 'No valid analysis'; - end - return; - end - - A = it.analysis; - S.txtDetect.Value = sprintf('%s | %s', A.detectMode, A.detectMsg); - S.txtWindow.Value = sprintf('%s | %s', A.windowMode, A.voltageMode); - S.txtCathIV.Value = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', A.Ic_est_A, A.Vc_ss_V, A.dVc_V); - S.txtAnodIV.Value = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', A.Ia_est_A, A.Va_ss_V, A.dVa_V); - S.txtCathBase.Value = sprintf('%.6f V', A.Vc_baseline_V); - S.txtAnodBase.Value = sprintf('%.6f V', A.Va_baseline_V); - S.txtCathBaseWin.Value = formatDurationUs(A.cathBaselineWindow_s); - S.txtAnodBaseWin.Value = formatDurationUs(A.anodBaselineWindow_s); - S.txtCathR.Value = sprintf('%.6g ohm (signed %.6g)', A.Rc_abs_ohm, A.Rc_ohm); - S.txtAnodR.Value = sprintf('%.6g ohm (signed %.6g)', A.Ra_abs_ohm, A.Ra_ohm); - S.txtAvgR.Value = sprintf('%.6g ohm', A.Ravg_abs_ohm); - S.txtStatus.Value = A.message; - end - - function out = chronoControlModeText(item) - out = 'Unknown chrono control mode'; - if ~isfield(item, 'controlMode') - return; - end - - switch string(item.controlMode) - case "current" - out = 'Current-controlled chrono'; - case "voltage" - out = 'Voltage-controlled chrono'; - otherwise - out = 'Unknown chrono control mode'; - end - end - - function refreshPlots() - labkit.ui.view.draw(axTop, 'clear'); - labkit.ui.view.draw(axBottom, 'clear'); - if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) - title(axTop,'Top Plot'); - title(axBottom,'Bottom Plot'); - return; - end - - it = S.items(S.current); - if isempty(it.analysis) || ~it.analysis.ok - title(axTop,'Top Plot'); - title(axBottom,'Bottom Plot'); - text(axTop,0.5,0.5,'No valid analysis','Units','normalized','HorizontalAlignment','center'); - return; - end - A = it.analysis; - plotOneAxis(axTop, A, ddTopX.Value, ddTopY.Value, cbTopGrid.Value); - plotOneAxis(axBottom, A, ddBotX.Value, ddBotY.Value, cbBotGrid.Value); - end - - function plotOneAxis(ax, A, xChoice, yChoice, showGrid) - if strcmp(xChoice,'Sample #') - x = A.pt; - xlab = 'Sample #'; - cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); - cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); - anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); - anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); - cathBaseStartX = interp1Safe(A.t, A.pt, A.pulse.pre_start); - cathBaseEndX = interp1Safe(A.t, A.pt, A.pulse.pre_end); - anodBaseStartX = interp1Safe(A.t, A.pt, A.anodBaselineStart); - anodBaseEndX = interp1Safe(A.t, A.pt, A.anodBaselineEnd); - cSteadyStartX = interp1Safe(A.t, A.pt, A.cathSteadyStart); - cSteadyEndX = interp1Safe(A.t, A.pt, A.cathSteadyEnd); - aSteadyStartX = interp1Safe(A.t, A.pt, A.anodSteadyStart); - aSteadyEndX = interp1Safe(A.t, A.pt, A.anodSteadyEnd); - else - x = A.t; - xlab = 'Time (s)'; - cathStartX = A.pulse.cath_start; - cathEndX = A.pulse.cath_end; - anodStartX = A.pulse.anod_start; - anodEndX = A.pulse.anod_end; - cathBaseStartX = A.pulse.pre_start; - cathBaseEndX = A.pulse.pre_end; - anodBaseStartX = A.anodBaselineStart; - anodBaseEndX = A.anodBaselineEnd; - cSteadyStartX = A.cathSteadyStart; - cSteadyEndX = A.cathSteadyEnd; - aSteadyStartX = A.anodSteadyStart; - aSteadyEndX = A.anodSteadyEnd; - end - - if startsWith(yChoice,'VT') - plot(ax, x, A.Vf, 'LineWidth',1.25, 'Color',[0 0.4470 0.7410]); - ylab = 'Vf (V vs Ref.)'; - ttl = sprintf('%s | VT | Ravg = %.6g ohm', itName(), A.Ravg_abs_ohm); - hold(ax,'on'); - else - plot(ax, x, A.Im, 'LineWidth',1.25, 'Color',[0.8500 0.3250 0.0980]); - ylab = 'Im (A)'; - ttl = sprintf('%s | IT | Ic %.4g A, Ia %.4g A', itName(), A.Ic_est_A, A.Ia_est_A); - hold(ax,'on'); - end - - if cbShowShading.Value - shadeWindow(ax, cathStartX, cathEndX, [0.90 0.95 1.00], 0.12); - shadeWindow(ax, anodStartX, anodEndX, [1.00 0.94 0.88], 0.12); - shadeWindow(ax, cSteadyStartX, cSteadyEndX, [0.65 0.82 1.00], 0.22); - shadeWindow(ax, aSteadyStartX, aSteadyEndX, [1.00 0.75 0.55], 0.22); - end - if cbShowMarkers.Value - xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); - xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); - xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); - xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); - if startsWith(yChoice,'VT') - addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... - cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX); - else - addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... - cathStartX, cathEndX, anodStartX, anodEndX); - end - end - hold(ax,'off'); - - title(ax, ttl, 'Interpreter','none'); - xlabel(ax, xlab); - ylabel(ax, ylab); - grid(ax, ternary(showGrid,'on','off')); - end - - function nm = itName() - if isempty(S.items) || isempty(S.current) - nm = 'file'; - else - nm = S.items(S.current).name; - end - end - - function swapPlots() - labkit.ui.view.update(plotControls, 'swapPlotSelections'); - refreshPlots(); - end - - function resetAxes() - resetAxesToDefaultState(); - refreshPlots(); - end - - function restoreDefaultPlotSelections() - labkit.ui.view.update(plotControls, 'setPlotSelections', ... - topPlotDefaults, bottomPlotDefaults); - end - - function resetAxesToDefaultState() - labkit.ui.view.draw(axTop, 'reset', 'Top Plot'); - labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot'); - end - - function exportResultsCSV() - if isempty(S.items) - uialert(fig,'No results to export.','Export'); - return; - end - [f,p] = uiputfile('vt_steady_resistance_results.csv','Save results CSV'); - if isequal(f,0) - return; - end - out = fullfile(p,f); - [ok, msg] = writeResultsCSV(S.items, out); - if ~ok - uialert(fig,msg,'Export'); - return; - end - addLog(['Exported CSV: ' out]); - end - - function addLog(msg) - labkit.ui.view.update(txtLog, 'appendLog', msg); - debugLog.append(msg); - end - -end - -%% App test hook -function handlers = vtAppTestHandlers() - handlers = struct( ... - 'command', {'computeResistance', 'buildBatchTableData', ... - 'buildResultsTable', 'writeResultsCSV'}, ... - 'minArgs', {2, 1, 1, 2}, ... - 'maxArgs', {2, 1, 1, 2}, ... - 'maxOutputs', {1, 1, 1, 2}, ... - 'run', {@runComputeResistance, @runBuildBatchTableData, ... - @runBuildResultsTable, @runWriteResultsCSV}); -end - -function outputs = runComputeResistance(args) - outputs = {computeResistance(args{1}, args{2})}; -end - -function outputs = runBuildBatchTableData(args) - outputs = {buildBatchTableData(args{1})}; -end - -function outputs = runBuildResultsTable(args) - outputs = {buildResultsTable(args{1})}; -end - -function outputs = runWriteResultsCSV(args) - [ok, msg] = writeResultsCSV(args{1}, args{2}); - outputs = {ok, msg}; -end - -%% App-local analysis -function A = computeResistance(item, opts) -%COMPUTERESISTANCE Compute VT resistance metrics for the VT app. - - if nargin < 2 - opts = struct(); - end - opts = fillResistanceOptions(opts); - - A = struct(); - A.ok = false; - A.message = ''; - A.windowMode = opts.windowMode; - A.voltageMode = opts.voltageMode; - A.logOnFailure = false; - - [curve, okCurve, msgCurve] = mainCurve(item); - if ~okCurve - A.message = msgCurve; - A.logOnFailure = true; - return; - end - - t = labkit.dta.getColumn(curve, 'T'); - Vf = labkit.dta.getColumn(curve, 'Vf'); - Im = labkit.dta.getColumn(curve, 'Im'); - pt = labkit.dta.getColumn(curve, 'Pt'); - if isempty(pt) - pt = (0:numel(t)-1).'; - end - - valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); - t = t(valid); - Vf = Vf(valid); - Im = Im(valid); - pt = pt(valid); - if numel(t) < 5 - A.message = 'Not enough valid T/Vf/Im points.'; - return; - end - - A.t = t; - A.Vf = Vf; - A.Im = Im; - A.pt = pt; - - meta = struct(); - if isfield(item, 'meta') - meta = item.meta; - end - [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); - A.pulse = pulse; - A.detectMode = pulse.method; - A.detectMsg = pulseMsg; - if ~pulse.ok - A.message = pulseMsg; - A.logOnFailure = true; - return; - end - - [cStart, cEnd] = selectSteadyWindow(pulse.cath_start, pulse.cath_end, A.windowMode); - [aStart, aEnd] = selectSteadyWindow(pulse.anod_start, pulse.anod_end, A.windowMode); - cathMask = t >= cStart & t <= cEnd; - anodMask = t >= aStart & t <= aEnd; - if nnz(cathMask) < 2 || nnz(anodMask) < 2 - A.message = 'Steady windows are too short after pulse detection.'; - return; - end - - A.cathMask = cathMask; - A.anodMask = anodMask; - A.cathSteadyStart = cStart; - A.cathSteadyEnd = cEnd; - A.anodSteadyStart = aStart; - A.anodSteadyEnd = aEnd; - - A.Ic_est_A = median(Im(cathMask), 'omitnan'); - A.Ia_est_A = median(Im(anodMask), 'omitnan'); - A.Vc_ss_V = median(Vf(cathMask), 'omitnan'); - A.Va_ss_V = median(Vf(anodMask), 'omitnan'); - - A.cathBaselineStart = pulse.pre_start; - A.cathBaselineEnd = pulse.pre_end; - A.anodBaselineStart = pulse.post_start; - A.anodBaselineEnd = pulse.post_end; - [A.Vc_baseline_V, A.cathBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.pre_start, pulse.pre_end, 0); - [A.Va_baseline_V, A.anodBaselineWindow_s] = estimateBaseline( ... - t, Vf, pulse.post_start, pulse.post_end, chooseFinite(A.Vc_baseline_V, 0)); - - A.dVc_V = A.Vc_ss_V - A.Vc_baseline_V; - A.dVa_V = A.Va_ss_V - A.Va_baseline_V; - A.Rc_raw_ohm = safeDivide(A.Vc_ss_V, A.Ic_est_A); - A.Ra_raw_ohm = safeDivide(A.Va_ss_V, A.Ia_est_A); - A.Rc_dV_ohm = safeDivide(A.dVc_V, A.Ic_est_A); - A.Ra_dV_ohm = safeDivide(A.dVa_V, A.Ia_est_A); - - if strcmp(A.voltageMode, 'Raw Vf/I') - A.Rc_ohm = A.Rc_raw_ohm; - A.Ra_ohm = A.Ra_raw_ohm; - else - A.Rc_ohm = A.Rc_dV_ohm; - A.Ra_ohm = A.Ra_dV_ohm; - end - A.Rc_abs_ohm = abs(A.Rc_ohm); - A.Ra_abs_ohm = abs(A.Ra_ohm); - A.Ravg_abs_ohm = mean([A.Rc_abs_ohm, A.Ra_abs_ohm], 'omitnan'); - - A.ok = isfinite(A.Ravg_abs_ohm); - if A.ok - A.message = 'OK'; - else - A.message = 'Resistance could not be computed; check current and pulse detection.'; - A.logOnFailure = true; - end -end - -function opts = fillResistanceOptions(opts) - if ~isfield(opts, 'windowMode') - opts.windowMode = 'Full pulse median'; - end - if ~isfield(opts, 'voltageMode') - opts.voltageMode = 'Baseline-corrected dV/I'; - end - if ~isfield(opts, 'pulseMode') - opts.pulseMode = 'Metadata first, then auto'; - end -end - -%% App-local table/export helpers -function C = buildBatchTableData(items) -%BUILDBATCHTABLEDATA Build VT resistance uitable data. - - C = cell(numel(items), 9); - for i = 1:numel(items) - item = items(i); - C{i, 1} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - C{i, 2} = NaN; - C{i, 3} = NaN; - C{i, 4} = NaN; - C{i, 5} = NaN; - C{i, 6} = NaN; - C{i, 7} = NaN; - C{i, 8} = NaN; - C{i, 9} = 'parse/analyze failed'; - continue; - end - - C{i, 2} = A.Ic_est_A; - C{i, 3} = A.Ia_est_A; - C{i, 4} = A.Vc_ss_V; - C{i, 5} = A.Va_ss_V; - C{i, 6} = A.Rc_abs_ohm; - C{i, 7} = A.Ra_abs_ohm; - C{i, 8} = A.Ravg_abs_ohm; - C{i, 9} = A.detectMode; - end -end - -function T = buildResultsTable(items) -%BUILDRESULTSTABLE Build VT resistance CSV result table. - - file = cell(numel(items), 1); - Ic_A = NaN(numel(items), 1); - Ia_A = NaN(numel(items), 1); - Vc_ss_V = NaN(numel(items), 1); - Va_ss_V = NaN(numel(items), 1); - Vc_baseline_V = NaN(numel(items), 1); - Va_baseline_V = NaN(numel(items), 1); - dVc_V = NaN(numel(items), 1); - dVa_V = NaN(numel(items), 1); - Rc_bc_ohm = NaN(numel(items), 1); - Ra_bc_ohm = NaN(numel(items), 1); - Ravg_bc_ohm = NaN(numel(items), 1); - windowMode = cell(numel(items), 1); - detection = cell(numel(items), 1); - status = cell(numel(items), 1); - - for i = 1:numel(items) - item = items(i); - file{i} = itemName(item); - A = itemAnalysis(item); - if isempty(A) || ~isfield(A, 'ok') || ~A.ok - windowMode{i} = ''; - detection{i} = 'failed'; - status{i} = analysisMessage(A); - continue; - end - - Ic_A(i) = A.Ic_est_A; - Ia_A(i) = A.Ia_est_A; - Vc_ss_V(i) = A.Vc_ss_V; - Va_ss_V(i) = A.Va_ss_V; - Vc_baseline_V(i) = A.Vc_baseline_V; - Va_baseline_V(i) = A.Va_baseline_V; - dVc_V(i) = A.dVc_V; - dVa_V(i) = A.dVa_V; - Rc_bc_ohm(i) = abs(A.Rc_dV_ohm); - Ra_bc_ohm(i) = abs(A.Ra_dV_ohm); - Ravg_bc_ohm(i) = mean([Rc_bc_ohm(i), Ra_bc_ohm(i)], 'omitnan'); - windowMode{i} = A.windowMode; - detection{i} = A.detectMode; - status{i} = A.message; - end - - T = table(file, Ic_A, Ia_A, Vc_ss_V, Va_ss_V, Vc_baseline_V, Va_baseline_V, ... - dVc_V, dVa_V, Rc_bc_ohm, Ra_bc_ohm, Ravg_bc_ohm, windowMode, detection, status, ... - 'VariableNames', {'File', 'Ic_A', 'Ia_A', 'Vc_ss_V', 'Va_ss_V', ... - 'Vc_baseline_V', 'Va_baseline_V', 'dVc_V', 'dVa_V', 'Rc_bc_ohm', ... - 'Ra_bc_ohm', 'Ravg_bc_ohm', 'WindowMode', 'Detection', 'Status'}); -end - -function [ok, msg] = writeResultsCSV(items, filepath) -%WRITERESULTSCSV Write VT resistance results in legacy CSV format. - - ok = true; - msg = ''; - - fid = fopen(filepath, 'w'); - if fid < 0 - ok = false; - msg = 'Could not open file for writing.'; - if nargout == 0 - error(msg); - end - return; - end - cleaner = onCleanup(@() fclose(fid)); - - try - T = buildResultsTable(items); - fprintf(fid, 'File,Ic_A,Ia_A,Vc_ss_V,Va_ss_V,Vc_baseline_V,Va_baseline_V,dVc_V,dVa_V,Rc_bc_ohm,Ra_bc_ohm,Ravg_bc_ohm,WindowMode,Detection,Status\n'); - for i = 1:height(T) - fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,"%s","%s","%s"\n', ... - csvEscape(T.File{i}), ... - T.Ic_A(i), T.Ia_A(i), T.Vc_ss_V(i), T.Va_ss_V(i), ... - T.Vc_baseline_V(i), T.Va_baseline_V(i), T.dVc_V(i), T.dVa_V(i), ... - T.Rc_bc_ohm(i), T.Ra_bc_ohm(i), T.Ravg_bc_ohm(i), ... - csvEscape(T.WindowMode{i}), ... - csvEscape(T.Detection{i}), ... - csvEscape(T.Status{i})); - end - catch ME - ok = false; - msg = ME.message; - if nargout == 0 - rethrow(ME); - end - end -end - -%% App-local plotting helpers -function [curve, ok, msg] = mainCurve(item) - if isfield(item, 'curve') && ~isempty(item.curve) - curve = item.curve; - ok = true; - msg = sprintf('Using table: %s', curve.name); - elseif isfield(item, 'tables') - [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); - else - curve = struct(); - ok = false; - msg = 'Main transient table not found.'; - end -end - -function q = safeDivide(a, b) - if ~isscalar(a) || ~isscalar(b) || ~isfinite(a) || ~isfinite(b) || abs(b) < eps - q = NaN; - else - q = a / b; - end -end - -function v = chooseFinite(varargin) - v = NaN; - for k = 1:nargin - x = varargin{k}; - if isscalar(x) && isfinite(x) - v = x; - return; - end - end -end - -function [t1, t2] = selectSteadyWindow(p1, p2, modeText) - t1 = p1; - t2 = p2; - if strcmp(modeText, 'Center 60% median') && isfinite(p1) && isfinite(p2) && p2 > p1 - dt = p2 - p1; - t1 = p1 + 0.20 * dt; - t2 = p1 + 0.80 * dt; - end -end - -function [v, window_s] = estimateBaseline(t, y, t1, t2, fallbackValue) - if nargin < 5 - fallbackValue = NaN; - end - - v = medianInWindow(t, y, t1, t2); - if ~isfinite(v) - v = fallbackValue; - end - window_s = max(0, t2 - t1); -end - -function name = itemName(item) - if isfield(item, 'name') - name = item.name; - else - name = ''; - end -end - -function A = itemAnalysis(item) - if isfield(item, 'analysis') - A = item.analysis; - else - A = []; - end -end - -function msg = analysisMessage(A) - msg = ''; - if ~isempty(A) && isfield(A, 'message') - msg = A.message; - end -end - -function out = ternary(cond, a, b) - if cond - out = a; - else - out = b; - end -end - -function shadeWindow(ax, x1, x2, color, alphaVal) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 - return; - end - yl = ylim(ax); - if any(~isfinite(yl)) || yl(1) == yl(2) - return; - end - p = patch(ax, [x1 x2 x2 x1], [yl(1) yl(1) yl(2) yl(2)], color, ... - 'FaceAlpha',alphaVal,'EdgeColor','none','HandleVisibility','off'); - uistack(p,'bottom'); -end - -function addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... - cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX) - cSteadyMidX = midpointFinite(cSteadyStartX, cSteadyEndX); - aSteadyMidX = midpointFinite(aSteadyStartX, aSteadyEndX); - - drawBaselineSegment(ax, cathBaseStartX, cathBaseEndX, A.Vc_baseline_V, [0.20 0.20 0.20], ... - sprintf('Cath baseline = %.4f V', A.Vc_baseline_V), 'bottom'); - drawBaselineSegment(ax, anodBaseStartX, anodBaseEndX, A.Va_baseline_V, [0.35 0.35 0.35], ... - sprintf('Anod baseline = %.4f V', A.Va_baseline_V), 'top'); - - drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Vc_ss_V, [0.10 0.35 0.80], '--'); - drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Va_ss_V, [0.80 0.35 0.10], '--'); - - plot(ax, cSteadyEndX, A.Vc_ss_V, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - plot(ax, aSteadyEndX, A.Va_ss_V, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - - text(ax, cSteadyEndX, A.Vc_ss_V, sprintf(' Cath steady V = %.4f V', A.Vc_ss_V), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); - text(ax, aSteadyEndX, A.Va_ss_V, sprintf(' Anod steady V = %.4f V', A.Va_ss_V), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); - - if isfinite(cSteadyMidX) && isfinite(A.Vc_baseline_V) && isfinite(A.Vc_ss_V) - plot(ax, [cSteadyMidX cSteadyMidX], [A.Vc_baseline_V A.Vc_ss_V], '--', ... - 'Color',[0.10 0.35 0.80], 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, cSteadyMidX, 0.5*(A.Vc_baseline_V + A.Vc_ss_V), sprintf(' Cath dV = %.4f V', A.dVc_V), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','middle', 'Interpreter','tex'); - end - if isfinite(aSteadyMidX) && isfinite(A.Va_baseline_V) && isfinite(A.Va_ss_V) - plot(ax, [aSteadyMidX aSteadyMidX], [A.Va_baseline_V A.Va_ss_V], '--', ... - 'Color',[0.80 0.35 0.10], 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, aSteadyMidX, 0.5*(A.Va_baseline_V + A.Va_ss_V), sprintf(' Anod dV = %.4f V', A.dVa_V), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','middle', 'Interpreter','tex'); - end - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08 * dy; - yLow = yl(2) - 0.16 * dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); - drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); -end - -function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... - cathStartX, cathEndX, anodStartX, anodEndX) - drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Ic_est_A, [0.10 0.35 0.80], '--'); - drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Ia_est_A, [0.80 0.35 0.10], '--'); - - plot(ax, cSteadyEndX, A.Ic_est_A, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - plot(ax, aSteadyEndX, A.Ia_est_A, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... - 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); - - text(ax, cSteadyEndX, A.Ic_est_A, sprintf(' Cath current = %.3f mA', 1e3 * A.Ic_est_A), ... - 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); - text(ax, aSteadyEndX, A.Ia_est_A, sprintf(' Anod current = %.3f mA', 1e3 * A.Ia_est_A), ... - 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); - - yl = ylim(ax); - dy = yl(2) - yl(1); - yTop = yl(2) - 0.08 * dy; - yLow = yl(2) - 0.16 * dy; - drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); - drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); -end - -function drawDurationBracket(ax, x1, x2, y, labelText) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - yl = ylim(ax); - h = 0.025 * (yl(2) - yl(1)); - plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); - text(ax, 0.5 * (x1 + x2), y + 1.4 * h, labelText, 'HorizontalAlignment','center', ... - 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1, 'HandleVisibility','off'); -end - -function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) - if ~isfinite(y) - return; - end - if isfinite(x1) && isfinite(x2) && x2 > x1 - xStart = x1; - xEnd = x2; - else - xl = xlim(ax); - xStart = xl(1) + 0.04 * (xl(2) - xl(1)); - xEnd = xStart + 0.18 * (xl(2) - xl(1)); - end - plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); - text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... - 'BackgroundColor','w', 'Margin',1, 'Interpreter','none', 'HandleVisibility','off'); -end - -function drawLevelSegment(ax, x1, x2, y, color, lineStyle) - if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) - return; - end - plot(ax, [x1 x2], [y y], lineStyle, 'Color', color, 'LineWidth',1.3, 'HandleVisibility','off'); -end - -function xm = midpointFinite(x1, x2) - if isfinite(x1) && isfinite(x2) - xm = 0.5 * (x1 + x2); - else - xm = NaN; - end -end - -function txt = formatDurationUs(dt_s) - if ~isscalar(dt_s) || ~isfinite(dt_s) || dt_s < 0 - txt = '-'; - else - txt = sprintf('%.3f us', 1e6 * dt_s); - end -end - -function s = csvEscape(x) - s = strrep(char(x), '"', '""'); -end - -function v = interp1Safe(x, y, xq) - if numel(x) < 2 || any(~isfinite([x(:); y(:)])) - v = NaN; - return; - end - - try - v = interp1(x, y, xq, 'linear', 'extrap'); - catch - idx = nearestIndex(x, xq); - v = y(idx); - end -end - -function idx = nearestIndex(x, xq) - [~, idx] = min(abs(x - xq)); -end - -function m = medianInWindow(t, y, t1, t2) - if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 - m = NaN; - return; - end - - mask = t >= t1 & t <= t2; - if ~any(mask) - m = NaN; - else - m = median(y(mask), 'omitnan'); - end end diff --git a/apps/electrochem/private/chronoOverlayWorkflow.m b/apps/electrochem/private/chronoOverlayWorkflow.m new file mode 100644 index 0000000..ed828e0 --- /dev/null +++ b/apps/electrochem/private/chronoOverlayWorkflow.m @@ -0,0 +1,282 @@ +% App-owned chrono overlay workflow helper dispatch. Expected caller: +% labkit_ChronoOverlay_app callbacks and workflow tests. +% Inputs are a command string plus the original helper arguments; outputs match +% the selected helper. This helper has no file side effects. +function varargout = chronoOverlayWorkflow(command, varargin) +%CHRONOOVERLAYWORKFLOW Dispatch app-owned chrono overlay helpers. +% Expected caller: labkit_ChronoOverlay_app callbacks and temporary compatibility +% workflow tests. Inputs are a command string plus the original helper arguments. +% Outputs match the selected helper. This helper has no file side effects. + + 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/private/cicWorkflow.m b/apps/electrochem/private/cicWorkflow.m new file mode 100644 index 0000000..558aa82 --- /dev/null +++ b/apps/electrochem/private/cicWorkflow.m @@ -0,0 +1,704 @@ +% App-owned CIC workflow helper dispatch. Expected caller: labkit_CIC_app +% callbacks and workflow tests. Inputs are a command +% string plus the original helper arguments; outputs match the selected helper. +% Side effects are limited to writeResultsCSV file writes. +function varargout = cicWorkflow(command, varargin) +%CICWORKFLOW Dispatch app-owned CIC analysis/export helpers. +% Expected caller: labkit_CIC_app callbacks and workflow tests. +% Inputs are a command string plus the original helper arguments. Outputs match +% the selected helper. Side effects are limited to writeResultsCSV file writes. + + 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{:}); + 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/private/cscWorkflow.m b/apps/electrochem/private/cscWorkflow.m new file mode 100644 index 0000000..3bc952b --- /dev/null +++ b/apps/electrochem/private/cscWorkflow.m @@ -0,0 +1,329 @@ +% App-owned CSC workflow helper dispatch. Expected caller: labkit_CSC_app +% callbacks and workflow tests. Inputs are a command +% string plus the original helper arguments; outputs match the selected helper. +% This helper has no file side effects. +function varargout = cscWorkflow(command, varargin) +%CSCWORKFLOW Dispatch app-owned CSC calculation helpers. +% Expected caller: labkit_CSC_app callbacks and workflow tests. Inputs are a +% command string plus the original helper arguments. +% Outputs match the selected helper. This helper has no file side effects. + + 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/private/eisWorkflow.m b/apps/electrochem/private/eisWorkflow.m new file mode 100644 index 0000000..71144af --- /dev/null +++ b/apps/electrochem/private/eisWorkflow.m @@ -0,0 +1,234 @@ +% App-owned EIS workflow helper dispatch. Expected caller: labkit_EIS_app +% callbacks and workflow tests. Inputs are a command +% string plus the original helper arguments; outputs match the selected helper. +% This helper has no file side effects. +function varargout = eisWorkflow(command, varargin) +%EISWORKFLOW Dispatch app-owned EIS plot/export helpers. +% Expected caller: labkit_EIS_app callbacks and workflow tests. Inputs are a +% command string plus the original helper arguments. +% Outputs match the selected helper. This helper has no file side effects. + + 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/private/runCICApp.m b/apps/electrochem/private/runCICApp.m new file mode 100644 index 0000000..20d4c3b --- /dev/null +++ b/apps/electrochem/private/runCICApp.m @@ -0,0 +1,1310 @@ +% App-owned runner extracted from labkit_CIC_app.m. Expected caller: labkit_CIC_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runCICApp(debugLog) +%RUNCICAPP Build and run the app body. + + S = struct(); + S.session = labkit.dta.makeSession('cic_vt'); + S.items = S.session.items; % loaded files + parsed content + analysis + S.current = []; + + %% ===================== Figure & Layout ===================== + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Gamry CIC GUI (Voltage Transient)', ... + 'position', [40 30 1680 980], ... + 'leftWidth', 430, ... + 'options', struct('rightKind', 'dualPlot'))); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + %% ===================== File panel ===================== + fileCallbacks = struct(); + fileCallbacks.onOpenFiles = @onOpenFiles; + fileCallbacks.onOpenFolder = @onOpenFolder; + fileCallbacks.onClearAll = @(~,~) clearAllFiles(); + fileCallbacks.onExport = @(~,~) exportResultsCSV(); + fileCallbacks.onSelectFile = @(~,~) onSelectFile(); + fileLabels = struct( ... + 'panelTitle', 'Files', ... + 'openFiles', 'Open DTA file(s)', ... + 'openFolder', 'Open folder recursively', ... + 'clearAll', 'Clear all', ... + 'export', 'Export results CSV', ... + 'loadedText', 'No files loaded'); + fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); + lbFiles = fileUi.listbox; + txtLoaded = fileUi.loadedText; + + %% ===================== Analysis settings ===================== + settingsUi = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [9 2]); + gs = settingsUi.grid; + + uilabel(gs,'Text','Window preset:','HorizontalAlignment','right'); + ddPreset = uidropdown(gs, ... + 'Items',{'Pt (-0.6 to 0.8 V)','PEDOT:PSS (-0.9 to 0.6 V)','Custom'}, ... + 'Value','Pt (-0.6 to 0.8 V)', ... + 'ValueChangedFcn',@(~,~) onPresetChanged()); + ddPreset.Layout.Row = 1; ddPreset.Layout.Column = 2; + + [lblCathLim, edCathLim] = labkit.ui.view.form(gs, 'spinner', 'Cathodic limit (V):', ... + 'Value', -0.6, 'Limits', [-10 10], 'Step', 0.01, ... + 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); + lblCathLim.Layout.Row = 2; lblCathLim.Layout.Column = 1; + edCathLim.Layout.Row = 2; edCathLim.Layout.Column = 2; + + [lblAnodLim, edAnodLim] = labkit.ui.view.form(gs, 'spinner', 'Anodic limit (V):', ... + 'Value', 0.8, 'Limits', [-10 10], 'Step', 0.01, ... + 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); + lblAnodLim.Layout.Row = 3; lblAnodLim.Layout.Column = 1; + edAnodLim.Layout.Row = 3; edAnodLim.Layout.Column = 2; + + [lblDelayUs, edDelayUs] = labkit.ui.view.form(gs, 'spinner', 'Sample delay after pulse end:', ... + 'Value', 10, 'Limits', [0 inf], 'Step', 1, ... + 'ValueDisplayFormat','%.6g','ValueChangedFcn',@(~,~) analyzeCurrentFile()); + lblDelayUs.Layout.Row = 4; lblDelayUs.Layout.Column = 1; + edDelayUs.Layout.Row = 4; edDelayUs.Layout.Column = 2; + + uilabel(gs,'Text','Area override (cm^2):','HorizontalAlignment','right'); + edArea = uieditfield(gs,'text','Value','', ... + 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + edArea.Layout.Row = 5; edArea.Layout.Column = 2; + + uilabel(gs,'Text','Pulse detection:','HorizontalAlignment','right'); + ddPulseMode = uidropdown(gs, ... + 'Items',{'Metadata first, then auto','Metadata only','Auto from Im only'}, ... + 'Value','Metadata first, then auto', ... + 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + ddPulseMode.Layout.Row = 6; ddPulseMode.Layout.Column = 2; + + uilabel(gs,'Text','CIC summary mode:','HorizontalAlignment','right'); + ddCICMode = uidropdown(gs, ... + 'Items',{'Cathodic phase','Anodic phase','Total biphasic'}, ... + 'Value','Total biphasic', ... + 'ValueChangedFcn',@(~,~) refreshResultsSummary()); + ddCICMode.Layout.Row = 7; ddCICMode.Layout.Column = 2; + + uilabel(gs,'Text','CIC unit:','HorizontalAlignment','right'); + ddCICUnit = uidropdown(gs, ... + 'Items',{'mC/cm^2','uC/cm^2'}, ... + 'Value','mC/cm^2', ... + 'ValueChangedFcn',@(~,~) refreshCICUnitDisplays()); + ddCICUnit.Layout.Row = 8; ddCICUnit.Layout.Column = 2; + + cbUseMeasuredCurrent = uicheckbox(gs,'Text','Use measured Im integration for charge (recommended)', ... + 'Value',true,'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + cbUseMeasuredCurrent.Layout.Row = 9; cbUseMeasuredCurrent.Layout.Column = [1 2]; + + %% ===================== Quick info ===================== + infoUi = labkit.ui.view.section(laySR, 'Current File Summary', 1, [11 2]); + gi = infoUi.grid; + + S.txtControlMode = labkit.ui.view.form(gi, 'info', 1, 'Control mode:'); + S.txtDetect = labkit.ui.view.form(gi, 'info', 2, 'Detection:'); + S.txtDelay = labkit.ui.view.form(gi, 'info', 3, 'Delay used:'); + S.txtArea = labkit.ui.view.form(gi, 'info', 4, 'Area:'); + S.txtEmc = labkit.ui.view.form(gi, 'info', 5, 'Emc:'); + S.txtEma = labkit.ui.view.form(gi, 'info', 6, 'Ema:'); + S.txtQc = labkit.ui.view.form(gi, 'info', 7, 'Cathodic Q/CIC:'); + S.txtQa = labkit.ui.view.form(gi, 'info', 8, 'Anodic Q/CIC:'); + S.txtQt = labkit.ui.view.form(gi, 'info', 9, 'Total Q/CIC:'); + S.txtSafe = labkit.ui.view.form(gi, 'info', 10, 'Safety:'); + S.txtBest = labkit.ui.view.form(gi, 'info', 11, 'Best safe among loaded:'); + + %% ===================== Actions ===================== + actionUi = labkit.ui.view.section(layFA, 'Plot / Debug', 3, [2 3]); + ga = actionUi.grid; + + btnRefresh = uibutton(ga,'Text','Refresh plots','ButtonPushedFcn',@(~,~) refreshPlots()); + btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 1; + btnSwap = uibutton(ga,'Text','Swap top / bottom','ButtonPushedFcn',@(~,~) swapPlots()); + btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 2; + btnReset = uibutton(ga,'Text','Reset axes','ButtonPushedFcn',@(~,~) resetAxes()); + btnReset.Layout.Row = 1; btnReset.Layout.Column = 3; + + cbShowMarkers = uicheckbox(ga,'Text','Show debug markers','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); + cbShowMarkers.Layout.Row = 2; cbShowMarkers.Layout.Column = 1; + cbShowLimits = uicheckbox(ga,'Text','Show window limits','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); + cbShowLimits.Layout.Row = 2; cbShowLimits.Layout.Column = 2; + cbShowShading = uicheckbox(ga,'Text','Shade pulse windows','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); + cbShowShading.Layout.Row = 2; cbShowShading.Layout.Column = 3; + + %% ===================== Results table ===================== + tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, ... + {'File','Amp(A)','Emc(V)','Ema(V)','Qc(mC/cm^2)','Qa(mC/cm^2)','Qtot(mC/cm^2)','Safe'}, ... + cell(0,8)); + tbl = tableUi.table; + + %% ===================== Log ===================== + logUi = labkit.ui.view.panel(layLog, 'log', 1); + txtLog = logUi.textArea; + + %% ===================== Right: plots ===================== + topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); + bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); + plotControls = labkit.ui.view.panel( ... + ui.topControlsPanel, ... + 'topBottomPlotControls', ... + ui.bottomControlsPanel, ... + {'Time (s)', 'Sample #'}, ... + {'VT: Vf vs time', 'IT: Im vs time'}, ... + topPlotDefaults, ... + bottomPlotDefaults, ... + @(~,~) refreshPlots()); + ddTopX = plotControls.topX; + ddTopY = plotControls.topY; + cbTopGrid = plotControls.topGridCheckbox; + axTop = ui.topAxes; + ddBotX = plotControls.bottomX; + ddBotY = plotControls.bottomY; + cbBotGrid = plotControls.bottomGridCheckbox; + axBottom = ui.bottomAxes; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('CIC debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + %% App callbacks, session actions, refresh, plotting, and export + function onPresetChanged() + switch ddPreset.Value + case 'Pt (-0.6 to 0.8 V)' + edCathLim.Value = -0.6; + edAnodLim.Value = 0.8; + case 'PEDOT:PSS (-0.9 to 0.6 V)' + edCathLim.Value = -0.9; + edAnodLim.Value = 0.6; + otherwise + % keep manual values + end + analyzeCurrentFile(); + end + + function onOpenFiles(~,~) + [f,p] = uigetfile({'*.DTA;*.dta','Gamry DTA (*.DTA)';'*.*','All files'}, ... + 'Select one or more Gamry DTA files','MultiSelect','on'); + if isequal(f,0) + addLog('Open cancelled.'); + return; + end + + if ischar(f) || isstring(f) + f = {char(f)}; + end + + filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); + loadDTAFiles(filepaths); + end + + function onOpenFolder(~,~) + folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); + if isequal(folder,0) + addLog('Folder selection cancelled.'); + return; + end + + filepaths = labkit.dta.findFiles(folder); + if isempty(filepaths) + addLog(sprintf('No DTA files found under: %s', folder)); + uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); + return; + end + + addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); + loadDTAFiles(filepaths); + end + + function loadDTAFiles(filepaths) + if isempty(filepaths) + return; + end + + filepaths = unique(filepaths, 'stable'); + callbacks = struct(); + callbacks.onAdded = @(~, ~) []; + callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); + callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); + [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); + postProcessAddedItems(report.added); + S.items = S.session.items; + + refreshFileList(); + restoreDefaultPlotSelections(); + resetAxesToDefaultState(); + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + + if ~isempty(report.failed) + firstError = report.failed(1); + uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); + end + end + + function postProcessAddedItems(filepaths) + for iFile = 1:numel(filepaths) + idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); + if isempty(idx) + continue; + end + item = S.session.items(idx); + item.analysis = []; + + for ii = 1:numel(item.logmsg) + addLog(item.logmsg{ii}); + end + + item = analyzeItem(item); + S.session.items(idx) = item; + addLog(sprintf('Loaded: %s', filepaths{iFile})); + end + end + + function analyzeCurrentFile() + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + refreshResultsSummary(); + refreshPlots(); + return; + end + S.items(S.current) = analyzeItem(S.items(S.current)); + S.session.items = S.items; + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + end + + function item = analyzeItem(item) + opts = struct(); + opts.delay_s = edDelayUs.Value * 1e-6; + opts.cathLimit = edCathLim.Value; + opts.anodLimit = edAnodLim.Value; + opts.areaOverride = edArea.Value; + opts.pulseMode = ddPulseMode.Value; + opts.usedMeasuredCurrent = cbUseMeasuredCurrent.Value; + + A = cicWorkflow("computeCIC", item, opts); + item.analysis = A; + if A.ok + addLog(sprintf('%s: Emc=%.6f V, Ema=%.6f V, safe=%d', item.name, A.Emc, A.Ema, A.safe)); + elseif isfield(A, 'logOnFailure') && A.logOnFailure + addLog(sprintf('%s: %s', item.name, A.message)); + end + end + + function onSelectFile() + if isempty(lbFiles.Items) + S.current = []; + resetAxesToDefaultState(); + refreshResultsSummary(); + refreshPlots(); + return; + end + + idx = find(strcmp(lbFiles.Items, lbFiles.Value), 1); + if isempty(idx) + S.current = []; + else + S.current = idx; + end + + restoreDefaultPlotSelections(); + resetAxesToDefaultState(); + refreshResultsSummary(); + refreshPlots(); + end + + function clearAllFiles() + S.session = labkit.dta.makeSession('cic_vt'); + S.items = S.session.items; + S.current = []; + restoreDefaultPlotSelections(); + resetAxesToDefaultState(); + refreshFileList(); + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + addLog('Cleared all files.'); + end + + function refreshFileList() + if isempty(S.items) + labkit.ui.view.update(lbFiles, 'listSelection', {}); + txtLoaded.Value = fileLabels.loadedText; + S.current = []; + return; + end + + names = {S.items.name}; + [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); + S.current = idx(1); + txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); + end + + function refreshBatchTable() + [~, unitLabel] = cicDisplayUnit(); + [C, columnNames] = cicWorkflow("buildBatchTableData", S.items, unitLabel); + tbl.ColumnName = columnNames; + if isempty(S.items) + tbl.Data = cell(0,8); + return; + end + tbl.Data = C; + end + + function refreshResultsSummary() + % clear first + S.txtControlMode.Value = '-'; + S.txtDetect.Value = '-'; + S.txtDelay.Value = '-'; + S.txtArea.Value = '-'; + S.txtEmc.Value = '-'; + S.txtEma.Value = '-'; + S.txtQc.Value = '-'; + S.txtQa.Value = '-'; + S.txtQt.Value = '-'; + S.txtSafe.Value = '-'; + S.txtBest.Value = bestSafeString(); + + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + return; + end + + it = S.items(S.current); + S.txtControlMode.Value = chronoControlModeText(it); + if isempty(it.analysis) || ~it.analysis.ok + if ~isempty(it.analysis) && isfield(it.analysis,'message') + S.txtSafe.Value = it.analysis.message; + else + S.txtSafe.Value = 'No valid analysis'; + end + S.txtBest.Value = bestSafeString(); + return; + end + + A = it.analysis; + S.txtDetect.Value = sprintf('%s | %s', A.detectMode, A.detectMsg); + S.txtDelay.Value = sprintf('%.3f us', 1e6 * A.delay_s); + S.txtArea.Value = formatMaybeNum(A.area_cm2,'%.8g cm^2'); + S.txtEmc.Value = sprintf('%.6f V @ %.6fus', A.Emc, 1e6*A.t_emc); + S.txtEma.Value = sprintf('%.6f V @ %.6fus', A.Ema, 1e6*A.t_ema); + S.txtQc.Value = formatChargeDensity(A.Qc_C, A.CICc_mCcm2, ddCICUnit.Value); + S.txtQa.Value = formatChargeDensity(A.Qa_C, A.CICa_mCcm2, ddCICUnit.Value); + S.txtQt.Value = formatChargeDensity(A.Qt_C, A.CICt_mCcm2, ddCICUnit.Value); + S.txtSafe.Value = sprintf('%s | Emc>=%.3f? %d | Ema<=%.3f? %d', ... + ternary(A.safe,'SAFE','UNSAFE'), A.cathLimit, A.cathOK, A.anodLimit, A.anodOK); + S.txtBest.Value = bestSafeString(); + end + + function out = chronoControlModeText(item) + out = 'Unknown chrono control mode'; + if ~isfield(item, 'controlMode') + return; + end + + switch string(item.controlMode) + case "current" + out = 'Current-controlled chrono'; + case "voltage" + out = 'Voltage-controlled chrono'; + otherwise + out = 'Unknown chrono control mode'; + end + end + + function out = bestSafeString() + if isempty(S.items) + out = '-'; + return; + end + safeIdx = []; + vals = []; + for i = 1:numel(S.items) + if ~isempty(S.items(i).analysis) && S.items(i).analysis.ok && S.items(i).analysis.safe + safeIdx(end+1) = i; %#ok + vals(end+1) = selectedCICValue(S.items(i).analysis); %#ok + end + end + if isempty(safeIdx) + out = 'No safe file in current batch'; + return; + end + [~, imax] = max(vals); + ii = safeIdx(imax); + [scale, unitLabel] = cicDisplayUnit(); + out = sprintf('%s | %s = %.6g %s', S.items(ii).name, shortModeName(), scale * vals(imax), unitLabel); + end + + function refreshCICUnitDisplays() + refreshBatchTable(); + refreshResultsSummary(); + end + + function [scale, unitLabel] = cicDisplayUnit() + unitLabel = ddCICUnit.Value; + switch unitLabel + case 'uC/cm^2' + scale = 1e3; + otherwise + scale = 1; + unitLabel = 'mC/cm^2'; + end + end + + function v = selectedCICValue(A) + switch ddCICMode.Value + case 'Cathodic phase' + v = A.CICc_mCcm2; + case 'Anodic phase' + v = A.CICa_mCcm2; + otherwise + v = A.CICt_mCcm2; + end + end + + function s = shortModeName() + switch ddCICMode.Value + case 'Cathodic phase' + s = 'CICc'; + case 'Anodic phase' + s = 'CICa'; + otherwise + s = 'CICtotal'; + end + end + + function refreshPlots() + labkit.ui.view.draw(axTop, 'clear'); + labkit.ui.view.draw(axBottom, 'clear'); + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + title(axTop,'Top Plot'); + title(axBottom,'Bottom Plot'); + return; + end + + it = S.items(S.current); + if isempty(it.analysis) || ~it.analysis.ok + title(axTop,'Top Plot'); + title(axBottom,'Bottom Plot'); + text(axTop,0.5,0.5,'No valid analysis','Units','normalized','HorizontalAlignment','center'); + return; + end + + A = it.analysis; + plotOneAxis(axTop, A, ddTopX.Value, ddTopY.Value, cbTopGrid.Value); + plotOneAxis(axBottom, A, ddBotX.Value, ddBotY.Value, cbBotGrid.Value); + end + + function plotOneAxis(ax, A, xChoice, yChoice, showGrid) + if strcmp(xChoice,'Sample #') + x = A.pt; + xlab = 'Sample #'; + cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); + cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); + anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); + anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); + emcX = interp1Safe(A.t, A.pt, A.t_emc); + emaX = interp1Safe(A.t, A.pt, A.t_ema); + else + x = A.t; + xlab = 'Time (s)'; + cathStartX = A.pulse.cath_start; + cathEndX = A.pulse.cath_end; + anodStartX = A.pulse.anod_start; + anodEndX = A.pulse.anod_end; + emcX = A.t_emc; + emaX = A.t_ema; + end + + if startsWith(yChoice,'VT') + y = A.Vf; + ylab = 'Vf (V vs Ref.)'; + baseColor = [0 0.4470 0.7410]; + plot(ax, x, y, 'LineWidth',1.25, 'Color', baseColor); + hold(ax,'on'); + + if cbShowShading.Value + shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); + shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); + end + + if cbShowLimits.Value + yline(ax, A.cathLimit, '--', sprintf('Cath limit = %.3f V', A.cathLimit), ... + 'Color',[0.85 0.2 0.2],'LabelHorizontalAlignment','left'); + yline(ax, A.anodLimit, '--', sprintf('Anod limit = %.3f V', A.anodLimit), ... + 'Color',[0.85 0.2 0.2],'LabelHorizontalAlignment','left'); + end + + addBaselineYLines(ax, A); + + if cbShowMarkers.Value + xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); + xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); + xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); + xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); + addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); + end + hold(ax,'off'); + ttl = sprintf('%s | VT | %s', itName(), ternary(A.safe,'SAFE','UNSAFE')); + else + y = A.Im; + ylab = 'Im (A)'; + baseColor = [0.8500 0.3250 0.0980]; + plot(ax, x, y, 'LineWidth',1.25, 'Color', baseColor); + hold(ax,'on'); + + if cbShowShading.Value + shadeWindow(ax, cathStartX, cathEndX, [0.85 0.93 1.00]); + shadeWindow(ax, anodStartX, anodEndX, [1.00 0.92 0.85]); + end + + if cbShowMarkers.Value + xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); + xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); + xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); + xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); + addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX); + end + hold(ax,'off'); + ttl = sprintf('%s | IT | |I|max = %.4g A', itName(), A.ampEstimate_A); + end + + title(ax, ttl, 'Interpreter','none'); + xlabel(ax, xlab); + ylabel(ax, ylab); + grid(ax, ternary(showGrid,'on','off')); + end + + function nm = itName() + if isempty(S.items) || isempty(S.current), nm = 'file'; else, nm = S.items(S.current).name; end + end + + function swapPlots() + labkit.ui.view.update(plotControls, 'swapPlotSelections'); + refreshPlots(); + end + + function resetAxes() + resetAxesToDefaultState(); + refreshPlots(); + end + + function restoreDefaultPlotSelections() + labkit.ui.view.update(plotControls, 'setPlotSelections', ... + topPlotDefaults, bottomPlotDefaults); + end + + function resetAxesToDefaultState() + labkit.ui.view.draw(axTop, 'reset', 'Top Plot', true); + labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot', true); + end + + function exportResultsCSV() + if isempty(S.items) + uialert(fig,'No results to export.','Export'); + return; + end + [f,p] = uiputfile('cic_results.csv','Save results CSV'); + if isequal(f,0) + return; + end + out = fullfile(p,f); + [~, unitLabel] = cicDisplayUnit(); + [ok, msg] = cicWorkflow("writeResultsCSV", S.items, out, unitLabel); + if ~ok + uialert(fig,msg,'Export'); + return; + end + addLog(['Exported CSV: ' out]); + end + + %% ===================== Logging ===================== + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end + +end + +%% App-local analysis +function A = computeCIC(item, opts) +%COMPUTECIC Compute legacy-compatible CIC / voltage-transient metrics. + + if nargin < 2 + opts = struct(); + end + opts = fillCICOptions(opts); + + A = struct(); + A.ok = false; + A.message = ''; + A.delay_s = opts.delay_s; + A.cathLimit = opts.cathLimit; + A.anodLimit = opts.anodLimit; + A.area_cm2 = chooseArea(item, opts); + A.usedMeasuredCurrent = opts.usedMeasuredCurrent; + A.logOnFailure = false; + + [curve, okCurve, msgCurve] = mainCurve(item); + if ~okCurve + A.message = msgCurve; + A.logOnFailure = true; + return; + end + + t = labkit.dta.getColumn(curve, 'T'); + Vf = labkit.dta.getColumn(curve, 'Vf'); + Im = labkit.dta.getColumn(curve, 'Im'); + pt = labkit.dta.getColumn(curve, 'Pt'); + if isempty(pt) + pt = (0:numel(t)-1).'; + end + + valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); + t = t(valid); + Vf = Vf(valid); + Im = Im(valid); + pt = pt(valid); + if numel(t) < 5 + A.message = 'Not enough valid T/Vf/Im points.'; + return; + end + + A.t = t; + A.Vf = Vf; + A.Im = Im; + A.pt = pt; + A.sample_dt = median(diff(t)); + A.sample_dt_report = A.sample_dt; + A.ampEstimate_A = max(abs(Im)); + + meta = struct(); + if isfield(item, 'meta') + meta = item.meta; + end + [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); + A.pulse = pulse; + A.detectMode = pulse.method; + A.detectMsg = pulseMsg; + + if ~pulse.ok + A.message = pulseMsg; + A.logOnFailure = true; + return; + end + + V = computeVoltageTransientMetrics(t, Vf, pulse, A.delay_s); + A = mergeStructs(A, V); + + Q = computeInjectedCharge(t, Im, pulse, A.usedMeasuredCurrent); + A = mergeStructs(A, Q); + if ~Q.ok + A.message = Q.message; + return; + end + + if isfinite(A.area_cm2) && A.area_cm2 > 0 + A.CICc_mCcm2 = 1e3 * A.Qc_C / A.area_cm2; + A.CICa_mCcm2 = 1e3 * A.Qa_C / A.area_cm2; + A.CICt_mCcm2 = 1e3 * A.Qt_C / A.area_cm2; + else + A.CICc_mCcm2 = NaN; + A.CICa_mCcm2 = NaN; + A.CICt_mCcm2 = NaN; + end + + safety = checkWaterWindowSafety(A.Emc, A.Ema, A.cathLimit, A.anodLimit); + A = mergeStructs(A, safety); + + A.ok = true; + A.message = 'OK'; +end + +function opts = fillCICOptions(opts) + if ~isfield(opts, 'delay_s') + opts.delay_s = 10e-6; + end + if ~isfield(opts, 'cathLimit') + opts.cathLimit = -0.6; + end + if ~isfield(opts, 'anodLimit') + opts.anodLimit = 0.8; + end + if ~isfield(opts, 'areaOverride') + opts.areaOverride = ''; + end + if ~isfield(opts, 'area_cm2') + opts.area_cm2 = NaN; + end + if ~isfield(opts, 'pulseMode') + opts.pulseMode = 'Metadata first, then auto'; + end + if ~isfield(opts, 'usedMeasuredCurrent') + opts.usedMeasuredCurrent = true; + end +end + +function area = chooseArea(item, opts) + area = NaN; + if isfield(opts, 'areaOverride') + area = parsePositiveScalar(opts.areaOverride); + end + if ~isfinite(area) && isfield(opts, 'area_cm2') + area = parsePositiveScalar(opts.area_cm2); + end + if ~isfinite(area) && isfield(item, 'meta') && isfield(item.meta, 'area_cm2') ... + && isfinite(item.meta.area_cm2) && item.meta.area_cm2 > 0 + area = item.meta.area_cm2; + end +end + +function [curve, ok, msg] = mainCurve(item) + if isfield(item, 'curve') && ~isempty(item.curve) + curve = item.curve; + ok = true; + msg = sprintf('Using table: %s', curve.name); + elseif isfield(item, 'tables') + [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); + else + curve = struct(); + ok = false; + msg = 'Main transient table not found.'; + end +end + +function out = mergeStructs(out, in) + names = fieldnames(in); + for i = 1:numel(names) + out.(names{i}) = in.(names{i}); + end +end + +function V = computeVoltageTransientMetrics(t, Vf, pulse, delay_s) + V = struct(); + V.t_emc = pulse.cath_end + delay_s; + V.t_ema = pulse.anod_end + delay_s; + V.emc_idx = nearestIndex(t, V.t_emc); + V.ema_idx = nearestIndex(t, V.t_ema); + V.Emc = interp1Safe(t, Vf, V.t_emc); + V.Ema = interp1Safe(t, Vf, V.t_ema); + + V.Epre = medianInWindow(t, Vf, pulse.pre_start, pulse.pre_end); + V.Ebetween = medianInWindow(t, Vf, pulse.gap_start, pulse.gap_end); + V.Epost = medianInWindow(t, Vf, pulse.post_start, pulse.post_end); + [V.Eipp, V.baselineCathSource, V.baselineCathWindow] = chooseBaselineCandidate( ... + [V.Epre, V.Ebetween, V.Epost, 0], ... + {'pre-pulse median', 'interpulse median', 'post-pulse median', 'zero fallback'}, ... + [pulse.pre_start pulse.pre_end; pulse.gap_start pulse.gap_end; pulse.post_start pulse.post_end; NaN NaN]); + [V.Eipp_gap, V.baselineAnodSource, V.baselineAnodWindow] = chooseBaselineCandidate( ... + [V.Ebetween, V.Epre, V.Epost, V.Eipp], ... + {'interpulse median', 'pre-pulse median', 'post-pulse median', 'cathodic baseline fallback'}, ... + [pulse.gap_start pulse.gap_end; pulse.pre_start pulse.pre_end; pulse.post_start pulse.post_end; V.baselineCathWindow]); + + V.tc_s = max(0, pulse.cath_end - pulse.cath_start); + V.ta_s = max(0, pulse.anod_end - pulse.anod_start); + V.tip_s = max(0, pulse.anod_start - pulse.cath_end); + V.t_conset = pulse.cath_start + delay_s; + V.t_aonset = pulse.anod_start + delay_s; + V.Vc_on = interp1Safe(t, Vf, V.t_conset); + V.Va_on = interp1Safe(t, Vf, V.t_aonset); + V.Va_cath_mag = abs(V.Eipp - V.Vc_on); + V.Va_anod_mag = abs(V.Eipp_gap - V.Va_on); +end + +function Q = computeInjectedCharge(t, Im, pulse, useMeasuredCurrent) + if nargin < 4 + useMeasuredCurrent = true; + end + + Q = struct(); + cathMask = (t >= pulse.cath_start) & (t <= pulse.cath_end); + anodMask = (t >= pulse.anod_start) & (t <= pulse.anod_end); + Q.cathMask = cathMask; + Q.anodMask = anodMask; + + if sum(cathMask) < 2 || sum(anodMask) < 2 + Q.ok = false; + Q.message = 'Pulse windows too short after detection.'; + return; + end + + Q.Ic_est_A = median(Im(cathMask), 'omitnan'); + Q.Ia_est_A = median(Im(anodMask), 'omitnan'); + if ~isfinite(Q.Ic_est_A) + Q.Ic_est_A = pulse.Ic_nominal; + end + if ~isfinite(Q.Ia_est_A) + Q.Ia_est_A = pulse.Ia_nominal; + end + + if useMeasuredCurrent + Qc = abs(trapz(t(cathMask), Im(cathMask))); + Qa = abs(trapz(t(anodMask), Im(anodMask))); + else + Qc = abs(pulse.Ic_nominal * (pulse.cath_end - pulse.cath_start)); + Qa = abs(pulse.Ia_nominal * (pulse.anod_end - pulse.anod_start)); + end + + Q.Qc_C = Qc; + Q.Qa_C = Qa; + Q.Qt_C = Qc + Qa; + Q.ok = true; + Q.message = 'OK'; +end + +function safety = checkWaterWindowSafety(Emc, Ema, cathLimit, anodLimit) + safety = struct(); + safety.cathOK = Emc >= cathLimit; + safety.anodOK = Ema <= anodLimit; + safety.safe = safety.cathOK && safety.anodOK; + + if safety.safe + safety.limitSide = 'safe'; + elseif ~safety.cathOK && ~safety.anodOK + safety.limitSide = 'both exceeded'; + elseif ~safety.cathOK + safety.limitSide = 'cathodic exceeded'; + else + safety.limitSide = 'anodic exceeded'; + end +end + +%% App-local table/export helpers +function [C, columnNames] = buildBatchTableData(items, unitLabel) +%BUILDBATCHTABLEDATA Build legacy CIC batch uitable data. + + if nargin < 2 + unitLabel = 'mC/cm^2'; + end + [scale, unitLabel] = displayScale(unitLabel); + columnNames = {'File', 'Amp(A)', 'Emc(V)', 'Ema(V)', ... + ['Qc(' unitLabel ')'], ['Qa(' unitLabel ')'], ['Qtot(' unitLabel ')'], 'Safe'}; + + C = cell(numel(items), 8); + for i = 1:numel(items) + item = items(i); + C{i, 1} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + C{i, 2} = NaN; + C{i, 3} = NaN; + C{i, 4} = NaN; + C{i, 5} = NaN; + C{i, 6} = NaN; + C{i, 7} = NaN; + C{i, 8} = 'parse/analyze failed'; + continue; + end + + C{i, 2} = A.ampEstimate_A; + C{i, 3} = A.Emc; + C{i, 4} = A.Ema; + C{i, 5} = scale * A.CICc_mCcm2; + C{i, 6} = scale * A.CICa_mCcm2; + C{i, 7} = scale * A.CICt_mCcm2; + C{i, 8} = ternary(A.safe, 'safe', A.limitSide); + end +end + +function T = buildResultsTable(items, unitLabel) +%BUILDRESULTSTABLE Build legacy CIC CSV result table. + + if nargin < 2 + unitLabel = 'mC/cm^2'; + end + [scale, unitSuffix] = displayScaleSuffix(unitLabel); + + file = cell(numel(items), 1); + amp_A = NaN(numel(items), 1); + Emc_V = NaN(numel(items), 1); + Ema_V = NaN(numel(items), 1); + Qc_C = NaN(numel(items), 1); + Qa_C = NaN(numel(items), 1); + Qt_C = NaN(numel(items), 1); + CICc = NaN(numel(items), 1); + CICa = NaN(numel(items), 1); + CICt = NaN(numel(items), 1); + safe = zeros(numel(items), 1); + detection = cell(numel(items), 1); + + for i = 1:numel(items) + item = items(i); + file{i} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + detection{i} = 'failed'; + continue; + end + + amp_A(i) = A.ampEstimate_A; + Emc_V(i) = A.Emc; + Ema_V(i) = A.Ema; + Qc_C(i) = A.Qc_C; + Qa_C(i) = A.Qa_C; + Qt_C(i) = A.Qt_C; + CICc(i) = scale * A.CICc_mCcm2; + CICa(i) = scale * A.CICa_mCcm2; + CICt(i) = scale * A.CICt_mCcm2; + safe(i) = A.safe; + detection{i} = A.detectMode; + end + + T = table(file, amp_A, Emc_V, Ema_V, Qc_C, Qa_C, Qt_C, CICc, CICa, CICt, safe, detection, ... + 'VariableNames', {'File', 'Amp_A', 'Emc_V', 'Ema_V', 'Qc_C', 'Qa_C', 'Qt_C', ... + ['CICc_' unitSuffix], ['CICa_' unitSuffix], ['CICt_' unitSuffix], 'Safe', 'Detection'}); +end + +function [ok, msg] = writeResultsCSV(items, filepath, unitLabel) +%WRITERESULTSCSV Write CIC results in legacy CSV format. + + if nargin < 3 + unitLabel = 'mC/cm^2'; + end + + ok = true; + msg = ''; + + fid = fopen(filepath, 'w'); + if fid < 0 + ok = false; + msg = 'Could not open file for writing.'; + if nargout == 0 + error(msg); + end + return; + end + cleaner = onCleanup(@() fclose(fid)); + + try + T = buildResultsTable(items, unitLabel); + names = T.Properties.VariableNames; + fprintf(fid, 'File,Amp_A,Emc_V,Ema_V,Qc_C,Qa_C,Qt_C,%s,%s,%s,Safe,Detection\n', ... + names{8}, names{9}, names{10}); + for i = 1:height(T) + if strcmp(T.Detection{i}, 'failed') + fprintf(fid, '"%s",,,,,,,,,,0,"failed"\n', T.File{i}); + else + fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%d,"%s"\n', ... + T.File{i}, T.Amp_A(i), T.Emc_V(i), T.Ema_V(i), T.Qc_C(i), T.Qa_C(i), T.Qt_C(i), ... + T.(names{8})(i), T.(names{9})(i), T.(names{10})(i), T.Safe(i), T.Detection{i}); + end + end + catch ME + ok = false; + msg = ME.message; + if nargout == 0 + rethrow(ME); + end + end +end + +%% App-local plotting helpers +function [v, sourceLabel, window] = chooseBaselineCandidate(candidates, sourceLabels, windows) + v = NaN; + sourceLabel = 'unavailable'; + window = [NaN NaN]; + for k = 1:numel(candidates) + if isfinite(candidates(k)) + v = candidates(k); + sourceLabel = sourceLabels{k}; + if size(windows, 1) >= k + window = windows(k, :); + end + return; + end + end +end + +function [scale, unitLabel] = displayScale(unitLabel) + switch unitLabel + case 'uC/cm^2' + scale = 1e3; + otherwise + scale = 1; + unitLabel = 'mC/cm^2'; + end +end + +function [scale, unitSuffix] = displayScaleSuffix(unitLabel) + [scale, unitLabel] = displayScale(unitLabel); + unitSuffix = regexprep(unitLabel, '[\^/]', ''); +end + +function name = itemName(item) + if isfield(item, 'name') + name = item.name; + else + name = ''; + end +end + +function A = itemAnalysis(item) + if isfield(item, 'analysis') + A = item.analysis; + else + A = []; + end +end + +function out = formatChargeDensity(Q_C, cic_mCcm2, unitLabel) + if isfinite(cic_mCcm2) + switch unitLabel + case 'uC/cm^2' + cic = 1e3 * cic_mCcm2; + otherwise + cic = cic_mCcm2; + unitLabel = 'mC/cm^2'; + end + out = sprintf('%.6e C | %.6f %s', Q_C, cic, unitLabel); + else + out = sprintf('%.6e C | area unavailable', Q_C); + end +end + +function s = formatMaybeNum(v, fmt) + if isfinite(v) + s = sprintf(fmt, v); + else + s = 'NaN'; + end +end + +function txt = ternary(cond, a, b) + if cond + txt = a; + else + txt = b; + end +end + +function shadeWindow(ax, x1, x2, color) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 + return; + end + yl = ylim(ax); + patch(ax,[x1 x2 x2 x1],[yl(1) yl(1) yl(2) yl(2)],color, ... + 'FaceAlpha',0.25,'EdgeColor','none','HandleVisibility','off'); + uistack(findobj(ax,'Type','patch'),'bottom'); +end + +function labelPulseCharge(ax, x1, x2, Q, tagText) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 + return; + end + xm = 0.5 * (x1 + x2); + yl = ylim(ax); + y0 = yl(1) + 0.90 * (yl(2) - yl(1)); + text(ax, xm, y0, sprintf('%s = %.3e C', tagText, Q), ... + 'HorizontalAlignment','center','VerticalAlignment','middle', ... + 'BackgroundColor','w','Margin',2); +end + +function addPaperStyleVTAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) + yl = ylim(ax); + dy = yl(2) - yl(1); + yTop = yl(2) - 0.07*dy; + yMid = yl(1) + 0.55*dy; + yLow = yl(1) + 0.18*dy; + + if strcmp(xChoice,'Sample #') + cOnX = interp1Safe(A.t, A.pt, A.t_conset); + aOnX = interp1Safe(A.t, A.pt, A.t_aonset); + cathBase1 = interp1Safe(A.t, A.pt, A.baselineCathWindow(1)); + cathBase2 = interp1Safe(A.t, A.pt, A.baselineCathWindow(2)); + anodBase1 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(1)); + anodBase2 = interp1Safe(A.t, A.pt, A.baselineAnodWindow(2)); + else + cOnX = A.t_conset; + aOnX = A.t_aonset; + cathBase1 = A.baselineCathWindow(1); + cathBase2 = A.baselineCathWindow(2); + anodBase1 = A.baselineAnodWindow(1); + anodBase2 = A.baselineAnodWindow(2); + end + + plot(ax, emcX, A.Emc, 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); + plot(ax, emaX, A.Ema, 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',7); + plot(ax, cOnX, A.Vc_on, 's', 'MarkerFaceColor',[0.2 0.6 1.0], 'MarkerEdgeColor','k', 'MarkerSize',6); + plot(ax, aOnX, A.Va_on, 's', 'MarkerFaceColor',[1.0 0.6 0.2], 'MarkerEdgeColor','k', 'MarkerSize',6); + + if isfinite(A.Eipp) + drawBaselineSegment(ax, cathBase1, cathBase2, A.Eipp, [0.25 0.25 0.25], ... + sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), 'bottom'); + end + if isfinite(A.Eipp_gap) + drawBaselineSegment(ax, anodBase1, anodBase2, A.Eipp_gap, [0.45 0.45 0.45], ... + sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), 'top'); + end + + if isfinite(A.Eipp) && isfinite(A.Vc_on) + plot(ax, [cOnX cOnX], [A.Eipp A.Vc_on], '--', 'Color',[0.2 0.6 1.0], 'LineWidth',1.0); + text(ax, cOnX, 0.5*(A.Eipp + A.Vc_on), sprintf(' Va(c)=%.3f V', A.Va_cath_mag), ... + 'Color',[0.15 0.45 0.8], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); + end + if isfinite(A.Eipp_gap) && isfinite(A.Va_on) + plot(ax, [aOnX aOnX], [A.Eipp_gap A.Va_on], '--', 'Color',[0.95 0.55 0.2], 'LineWidth',1.0); + text(ax, aOnX, 0.5*(A.Eipp_gap + A.Va_on), sprintf(' Va(a)=%.3f V', A.Va_anod_mag), ... + 'Color',[0.75 0.35 0.05], 'VerticalAlignment','middle', 'HorizontalAlignment','left'); + end + + text(ax, emcX, A.Emc, sprintf(' Emc = %.4f V', A.Emc), 'VerticalAlignment','bottom', 'Color',[0.1 0.5 0.1]); + text(ax, emaX, A.Ema, sprintf(' Ema = %.4f V', A.Ema), 'VerticalAlignment','top', 'Color',[0.6 0.4 0]); + + drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); + drawDurationBracket(ax, anodStartX, anodEndX, yTop - 0.06*dy, sprintf('ta = %.3f ms', 1e3*A.ta_s)); + if A.tip_s > 0 && anodStartX > cathEndX + drawDurationBracket(ax, cathEndX, anodStartX, yLow, sprintf('tip = %.1f us', 1e6*A.tip_s)); + end + yline(ax, yMid, ':', 'Color',[0.8 0.8 0.8], 'HandleVisibility','off'); +end + +function addPaperStyleITAnnotations(ax, A, xChoice, cathStartX, cathEndX, anodStartX, anodEndX, emcX, emaX) + plot(ax, emcX, interp1Safe(chooseX(A,xChoice), A.Im, emcX), 'o', 'MarkerFaceColor',[0.1 0.7 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); + plot(ax, emaX, interp1Safe(chooseX(A,xChoice), A.Im, emaX), 'o', 'MarkerFaceColor',[0.95 0.8 0.1], 'MarkerEdgeColor','k', 'MarkerSize',6); + + plot(ax, [cathStartX cathEndX], [A.Ic_est_A A.Ic_est_A], '--', 'Color',[0.1 0.45 0.8], 'LineWidth',1.3); + plot(ax, [anodStartX anodEndX], [A.Ia_est_A A.Ia_est_A], '--', 'Color',[0.85 0.45 0.1], 'LineWidth',1.3); + text(ax, cathEndX, A.Ic_est_A, sprintf(' ic = %.3f mA', 1e3*A.Ic_est_A), 'Color',[0.1 0.35 0.75], 'VerticalAlignment','bottom'); + text(ax, anodEndX, A.Ia_est_A, sprintf(' ia = %.3f mA', 1e3*A.Ia_est_A), 'Color',[0.7 0.32 0.05], 'VerticalAlignment','top'); + + labelPulseCharge(ax, cathStartX, cathEndX, A.Qc_C, 'Qc'); + labelPulseCharge(ax, anodStartX, anodEndX, A.Qa_C, 'Qa'); + + yl = ylim(ax); + dy = yl(2) - yl(1); + yTop = yl(2) - 0.08*dy; + yMid = yl(2) - 0.16*dy; + drawDurationBracket(ax, cathStartX, cathEndX, yTop, sprintf('tc = %.3f ms', 1e3*A.tc_s)); + drawDurationBracket(ax, anodStartX, anodEndX, yTop, sprintf('ta = %.3f ms', 1e3*A.ta_s)); + if A.tip_s > 0 && anodStartX > cathEndX + drawDurationBracket(ax, cathEndX, anodStartX, yMid, sprintf('tip = %.1f us', 1e6*A.tip_s)); + end +end + +function drawDurationBracket(ax, x1, x2, y, labelText) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) + return; + end + yl = ylim(ax); + h = 0.025 * (yl(2) - yl(1)); + plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + text(ax, 0.5*(x1+x2), y + 1.4*h, labelText, 'HorizontalAlignment','center', ... + 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1); +end + +function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) + if ~isfinite(y) + return; + end + if isfinite(x1) && isfinite(x2) && x2 > x1 + xStart = x1; + xEnd = x2; + else + xl = xlim(ax); + xStart = xl(1) + 0.04 * (xl(2) - xl(1)); + xEnd = xStart + 0.18 * (xl(2) - xl(1)); + end + plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); + text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... + 'BackgroundColor','w', 'Margin',1, 'Interpreter','none'); +end + +function addBaselineYLines(ax, A) + if isfinite(A.Eipp) + yline(ax, A.Eipp, '--', ... + sprintf('Baseline(cath) = %.3f V [%s]', A.Eipp, shortBaselineSource(A.baselineCathSource)), ... + 'Color',[0.20 0.20 0.20], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','bottom'); + end + if isfinite(A.Eipp_gap) + yline(ax, A.Eipp_gap, '--', ... + sprintf('Baseline(anod) = %.3f V [%s]', A.Eipp_gap, shortBaselineSource(A.baselineAnodSource)), ... + 'Color',[0.40 0.40 0.40], 'LabelHorizontalAlignment','right', 'LabelVerticalAlignment','top'); + end +end + +function x = chooseX(A, xChoice) + if strcmp(xChoice, 'Sample #') + x = A.pt; + else + x = A.t; + end +end + +function v = chooseFinite(varargin) + v = NaN; + for k = 1:nargin + if isfinite(varargin{k}) + v = varargin{k}; + return; + end + end +end + +function s = shortBaselineSource(sourceLabel) + switch sourceLabel + case 'pre-pulse median' + s = 'pre'; + case 'interpulse median' + s = 'gap'; + case 'post-pulse median' + s = 'post'; + case 'zero fallback' + s = '0 V fallback'; + case 'cathodic baseline fallback' + s = 'cath fallback'; + otherwise + s = sourceLabel; + end +end + +function q = parsePositiveScalar(x) + if isnumeric(x) + q = x; + else + x = strtrim(char(x)); + if isempty(x) + q = NaN; + return; + end + q = str2double(x); + end + + if ~isscalar(q) || ~isfinite(q) || q <= 0 + q = NaN; + end +end + +function v = interp1Safe(x, y, xq) + if numel(x) < 2 || any(~isfinite([x(:); y(:)])) + v = NaN; + return; + end + + try + v = interp1(x, y, xq, 'linear', 'extrap'); + catch + idx = nearestIndex(x, xq); + v = y(idx); + end +end + +function idx = nearestIndex(x, xq) + [~, idx] = min(abs(x - xq)); +end + +function m = medianInWindow(t, y, t1, t2) + if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 + m = NaN; + return; + end + + mask = t >= t1 & t <= t2; + if ~any(mask) + m = NaN; + else + m = median(y(mask), 'omitnan'); + end +end diff --git a/apps/electrochem/private/runCSCApp.m b/apps/electrochem/private/runCSCApp.m new file mode 100644 index 0000000..1db978d --- /dev/null +++ b/apps/electrochem/private/runCSCApp.m @@ -0,0 +1,864 @@ +% App-owned runner extracted from labkit_CSC_app.m. Expected caller: labkit_CSC_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runCSCApp(debugLog) +%RUNCSCAPP Build and run the app body. + + S = struct(); + S.session = labkit.dta.makeSession('cv_csc'); + S.filepath = ''; + S.items = S.session.items; + S.current = []; + S.curves = struct('name',{},'headers',{},'units',{},'data',{},'numericMask',{}); + S.scanRate = NaN; % V/s + S.currentCurve = 1; + + %% ===================== Figure & Layout ===================== + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Gamry DTA GUI (literature CSC)', ... + 'position', [50 30 1580 950], ... + 'leftWidth', 390, ... + 'options', struct('rightKind', 'dualPlot'))); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + % -------- File panel -------- + fileCallbacks = struct(); + fileCallbacks.onOpenFiles = @onOpenFiles; + fileCallbacks.onOpenFolder = @onOpenFolder; + fileCallbacks.onClearAll = @(~,~) clearAllFiles(); + fileCallbacks.onExport = @(~,~) reloadSelectedFile(); + fileCallbacks.onSelectFile = @(~,~) onSelectFile(); + fileLabels = struct( ... + 'panelTitle', 'Files', ... + 'openFiles', 'Open DTA file(s)', ... + 'openFolder', 'Open folder recursively', ... + 'clearAll', 'Clear all', ... + 'export', 'Reload selected', ... + 'loadedText', 'No files loaded'); + fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); + lbFiles = fileUi.listbox; + txtLoaded = fileUi.loadedText; + + % -------- Curve -------- + curveUi = labkit.ui.view.section(layFA, 'Curve', 2, [4 2]); + gf = curveUi.grid; + + uilabel(gf,'Text','File:','HorizontalAlignment','right'); + txtFile = labkit.ui.view.form(gf, 'readonly'); + txtFile.Layout.Row = 1; txtFile.Layout.Column = 2; + + uilabel(gf,'Text','Scan rate:','HorizontalAlignment','right'); + txtScan = labkit.ui.view.form(gf, 'readonly'); + txtScan.Layout.Row = 2; txtScan.Layout.Column = 2; + + uilabel(gf,'Text','Curve:','HorizontalAlignment','right'); + ddCurve = uidropdown(gf,'Items',{'(none)'},'ValueChangedFcn',@(~,~) onCurveChanged()); + ddCurve.Layout.Row = 3; ddCurve.Layout.Column = 2; + + btnAuto = uibutton(gf,'Text','Auto CV + CT','ButtonPushedFcn',@(~,~) autoPresetAndRefresh()); + btnAuto.Layout.Row = 4; btnAuto.Layout.Column = [1 2]; + + % -------- Actions -------- + actionOpts = struct('columnWidth', {{'1x', '1x'}}); + actionUi = labkit.ui.view.section(layFA, 'Actions', 3, [2 2], actionOpts); + ga = actionUi.grid; + + btnSwap = uibutton(ga,'Text','Swap Top/Bottom','ButtonPushedFcn',@(~,~) onSwapPlots()); + btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 1; + btnCompare = uibutton(ga,'Text','Compare Q / CSC','ButtonPushedFcn',@(~,~) refreshCompare()); + btnCompare.Layout.Row = 1; btnCompare.Layout.Column = 2; + btnRefresh = uibutton(ga,'Text','Refresh Plots','ButtonPushedFcn',@(~,~) refreshPlotsOnly()); + btnRefresh.Layout.Row = 2; btnRefresh.Layout.Column = 1; + btnClear = uibutton(ga,'Text','Clear Both','ButtonPushedFcn',@(~,~) clearBothAxes()); + btnClear.Layout.Row = 2; btnClear.Layout.Column = 2; + + % -------- Comparison / CSC -------- + compUi = labkit.ui.view.section(laySR, 'CSC / Comparison', 1, [8 2]); + gc = compUi.grid; + + uilabel(gc,'Text','Mode:','HorizontalAlignment','right'); + ddMode = uidropdown(gc, ... + 'Items',{'Full','Cathodic','Anodic'}, ... + 'Value','Full', ... + 'ValueChangedFcn',@(~,~) refreshCompare()); + ddMode.Layout.Row = 1; ddMode.Layout.Column = 2; + + uilabel(gc,'Text','Area (cm^2):','HorizontalAlignment','right'); + edArea = uieditfield(gc,'text','Value',''); + edArea.ValueChangedFcn = @(~,~) refreshCompare(); + edArea.Layout.Row = 2; edArea.Layout.Column = 2; + + uilabel(gc,'Text','CT charge / CSC:','HorizontalAlignment','right'); + txtQct = labkit.ui.view.form(gc, 'readonly'); + txtQct.Layout.Row = 3; txtQct.Layout.Column = 2; + + uilabel(gc,'Text','CV charge / CSC:','HorizontalAlignment','right'); + txtQcv = labkit.ui.view.form(gc, 'readonly'); + txtQcv.Layout.Row = 4; txtQcv.Layout.Column = 2; + + uilabel(gc,'Text','Difference:','HorizontalAlignment','right'); + txtDiff = labkit.ui.view.form(gc, 'readonly'); + txtDiff.Layout.Row = 5; txtDiff.Layout.Column = 2; + + uilabel(gc,'Text','Relative diff:','HorizontalAlignment','right'); + txtRel = labkit.ui.view.form(gc, 'readonly'); + txtRel.Layout.Row = 6; txtRel.Layout.Column = 2; + + uilabel(gc,'Text','max|dt-|dV|/v|:','HorizontalAlignment','right'); + txtDtErr = labkit.ui.view.form(gc, 'readonly'); + txtDtErr.Layout.Row = 7; txtDtErr.Layout.Column = 2; + + lblStatus = uilabel(gc,'Text','Ready'); + lblStatus.Layout.Row = 8; lblStatus.Layout.Column = [1 2]; + lblStatus.FontWeight = 'bold'; + + % -------- Log -------- + logUi = labkit.ui.view.panel(layLog, 'log', 1, {'GUI started.'}); + txtLog = logUi.textArea; + txtLog.Value = {'GUI started.'}; + + % -------- Top/bottom controls -------- + topPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); + bottomPlotDefaults = struct('x', '(none)', 'y', '(none)', 'grid', true); + plotControls = labkit.ui.view.panel( ... + ui.topControlsPanel, ... + 'topBottomPlotControls', ... + ui.bottomControlsPanel, ... + {'(none)'}, ... + {'(none)'}, ... + topPlotDefaults, ... + bottomPlotDefaults, ... + @(~,~) refreshPlotsOnly()); + ddTopX = plotControls.topX; + ddTopY = plotControls.topY; + cbTopGrid = plotControls.topGridCheckbox; + ddBotX = plotControls.bottomX; + ddBotY = plotControls.bottomY; + cbBotGrid = plotControls.bottomGridCheckbox; + axTop = ui.topAxes; + axBottom = ui.bottomAxes; + title(axTop,'Top Plot'); + xlabel(axTop,'X'); + ylabel(axTop,'Y'); + title(axBottom,'Bottom Plot'); + xlabel(axBottom,'X'); + ylabel(axBottom,'Y'); + + plotControls.topGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; + cbTopHold = uicheckbox(plotControls.topGrid,'Text','Hold','Value',false); + cbTopHold.Layout.Row = 1; cbTopHold.Layout.Column = 6; + cbTopTrim = uicheckbox(plotControls.topGrid,'Text','Show Trim','Value',true, ... + 'ValueChangedFcn',@(~,~) refreshCompare()); + cbTopTrim.Layout.Row = 1; cbTopTrim.Layout.Column = 7; + + plotControls.bottomGrid.ColumnWidth = {'fit','1x','fit','1x','fit','fit','fit'}; + cbBotHold = uicheckbox(plotControls.bottomGrid,'Text','Hold','Value',false); + cbBotHold.Layout.Row = 1; cbBotHold.Layout.Column = 6; + cbBotTrim = uicheckbox(plotControls.bottomGrid,'Text','Show Trim','Value',true, ... + 'ValueChangedFcn',@(~,~) refreshCompare()); + cbBotTrim.Layout.Row = 1; cbBotTrim.Layout.Column = 7; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('CSC debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + %% App callbacks, loading, refresh, and plotting + function onOpenFiles(~,~) + [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... + 'Select Gamry DTA file(s)','MultiSelect','on'); + if isequal(files,0) + addLog('Open file canceled.'); + return; + end + if ischar(files) || isstring(files) + files = {char(files)}; + end + filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); + addFiles(filepaths); + end + + function onOpenFolder(~,~) + folder = uigetdir(pwd,'Select folder containing DTA files'); + if isequal(folder,0) + addLog('Folder selection canceled.'); + return; + end + filepaths = labkit.dta.findFiles(folder); + if isempty(filepaths) + uialert(fig,'No .DTA files found in the selected folder.','Open folder'); + addLog(['No .DTA files found under: ' folder]); + return; + end + addFiles(filepaths); + end + + function addFiles(filepaths) + if isempty(filepaths) + return; + end + + callbacks = struct(); + callbacks.onAdded = @(~, item) onAddedItem(item); + callbacks.onSkipped = @(filepath) addLog(['Skipped duplicate: ' filepath]); + callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed to load %s: %s', filepath, message)); + [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "cvct", callbacks); + S.items = S.session.items; + if ~isempty(S.items) && isempty(S.current) + S.current = 1; + end + refreshFileList(); + loadCurrentItem(); + + if ~isempty(report.failed) + firstError = report.failed(1); + uialert(fig, sprintf('Failed to load:\n%s\n\n%s', ... + firstError.filepath, firstError.message), 'Load error'); + end + end + + function onAddedItem(item) + for i = 1:numel(item.logmsg) + addLog(item.logmsg{i}); + end + addLog(['Loaded: ' item.filepath]); + end + + function onSelectFile() + if isempty(S.items) || isempty(lbFiles.Value) + return; + end + idx = find(strcmp({S.items.name}, lbFiles.Value), 1); + if isempty(idx) + idx = 1; + end + S.current = idx; + loadCurrentItem(); + end + + function clearAllFiles() + S.session = labkit.dta.makeSession('cv_csc'); + S.items = S.session.items; + S.current = []; + clearCurrentItem(); + refreshFileList(); + clearBothAxes(); + addLog('Cleared all files.'); + end + + function reloadSelectedFile() + if isempty(S.items) || isempty(S.current) + uialert(fig,'No file selected.','Reload'); + addLog('Reload failed: no file selected.'); + return; + end + filepath = S.items(S.current).filepath; + [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, {S.items(S.current).name}, struct()); + S.items = S.session.items; + S.current = []; + addFiles({filepath}); + end + + function refreshFileList() + if isempty(S.items) + labkit.ui.view.update(lbFiles, 'listSelection', {}); + txtLoaded.Value = 'No files loaded'; + return; + end + [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', {S.items.name}, S.current); + S.current = idx(1); + txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); + end + + function loadCurrentItem() + if isempty(S.items) + clearCurrentItem(); + return; + end + if isempty(S.current) || S.current < 1 || S.current > numel(S.items) + S.current = 1; + end + S.session.items(S.current).currentCurve = 1; + S.session.items(S.current).analysis = []; + S.items = S.session.items; + item = S.items(S.current); + S.filepath = item.filepath; + S.scanRate = item.scanRate; + S.curves = item.curves; + S.currentCurve = 1; + txtFile.Value = item.filepath; + + if isnan(S.scanRate) + txtScan.Value = 'Not found'; + else + txtScan.Value = sprintf('%.6f V/s (%.3f mV/s)', S.scanRate, S.scanRate*1000); + end + + if isempty(S.curves) + ddCurve.Items = {'(none)'}; + ddCurve.Value = '(none)'; + lblStatus.Text = 'No curve found'; + addLog('No curve parsed.'); + return; + end + + items = cell(1,numel(S.curves)); + for k = 1:numel(S.curves) + items{k} = sprintf('%s (%d rows)', S.curves(k).name, size(S.curves(k).data,1)); + end + ddCurve.Items = items; + ddCurve.Value = items{1}; + + lblStatus.Text = sprintf('Loaded %d curve(s)', numel(S.curves)); + addLog(sprintf('Loaded %d curve(s) from %s.', numel(S.curves), item.name)); + + updateDropdowns(); + autoSetDefaults(); + refreshAll(); + end + + function clearCurrentItem() + S.filepath = ''; + S.scanRate = NaN; + S.curves = struct('name',{},'headers',{},'units',{},'data',{},'numericMask',{}); + S.currentCurve = 1; + txtFile.Value = ''; + txtScan.Value = ''; + ddCurve.Items = {'(none)'}; + ddCurve.Value = '(none)'; + lblStatus.Text = 'Ready'; + txtQct.Value = ''; + txtQcv.Value = ''; + txtDiff.Value = ''; + txtRel.Value = ''; + txtDtErr.Value = ''; + end + + function onCurveChanged() + if isempty(S.curves) + return; + end + idx = find(strcmp(ddCurve.Items, ddCurve.Value),1); + if isempty(idx), idx = 1; end + S.currentCurve = idx; + syncSessionCurrentCurve(); + addLog(sprintf('Selected curve %d', idx)); + updateDropdowns(); + autoSetDefaults(); + refreshAll(); + end + + function autoPresetAndRefresh() + autoSetDefaults(); + refreshAll(); + end + + function onSwapPlots() + tx = ddTopX.Value; ty = ddTopY.Value; + bx = ddBotX.Value; by = ddBotY.Value; + + if any(strcmp(ddTopX.Items,bx)), ddTopX.Value = bx; end + if any(strcmp(ddTopY.Items,by)), ddTopY.Value = by; end + if any(strcmp(ddBotX.Items,tx)), ddBotX.Value = tx; end + if any(strcmp(ddBotY.Items,ty)), ddBotY.Value = ty; end + + addLog('Swapped top/bottom selections.'); + refreshPlotsOnly(); + refreshCompare(); + end + + function clearBothAxes() + cla(axTop); + cla(axBottom); + title(axTop,'Top Plot'); xlabel(axTop,'X'); ylabel(axTop,'Y'); + title(axBottom,'Bottom Plot'); xlabel(axBottom,'X'); ylabel(axBottom,'Y'); + addLog('Cleared both axes.'); + end + + function syncSessionCurrentCurve() + if ~isempty(S.session.items) && ~isempty(S.current) + S.session.items(S.current).currentCurve = S.currentCurve; + S.items = S.session.items; + end + end + + function updateDropdowns() + if isempty(S.curves), return; end + c = S.curves(S.currentCurve); + cols = c.headers(c.numericMask); + if isempty(cols) + cols = {'(none)'}; + end + ddTopX.Items = cols; + ddTopY.Items = cols; + ddBotX.Items = cols; + ddBotY.Items = cols; + addLog(['Numeric columns: ' strjoin(cols, ', ')]); + end + + function autoSetDefaults() + if isempty(S.curves), return; end + setDropdownValueIfExists(ddTopX,'Vf'); + setDropdownValueIfExists(ddTopY,'Im'); + setDropdownValueIfExists(ddBotX,'T'); + setDropdownValueIfExists(ddBotY,'Im'); + end + + function refreshPlotsOnly() + if isempty(S.curves), return; end + plotTop(); + plotBottom(); + end + + function refreshAll() + refreshPlotsOnly(); + refreshCompare(); + end + + function plotTop() + if isempty(S.curves), return; end + c = S.curves(S.currentCurve); + opts = struct('holdPlot', cbTopHold.Value, 'showGrid', cbTopGrid.Value, 'lineWidth', 1.2); + [x, y, xName, yName] = labkit.dta.getCurveXY(c, ddTopX.Value, ddTopY.Value); + labels = struct('title', c.name, 'x', xName, 'y', yName); + info = labkit.ui.view.draw(axTop, 'xy', x, y, labels, opts); + if ~info.ok + addLog('Top plot skipped: invalid X/Y.'); + return; + end + addLog(sprintf('Top plot: %s vs %s, n=%d', info.yName, info.xName, numel(info.x))); + end + + function plotBottom() + if isempty(S.curves), return; end + c = S.curves(S.currentCurve); + opts = struct('holdPlot', cbBotHold.Value, 'showGrid', cbBotGrid.Value, 'lineWidth', 1.2); + [x, y, xName, yName] = labkit.dta.getCurveXY(c, ddBotX.Value, ddBotY.Value); + labels = struct('title', c.name, 'x', xName, 'y', yName); + info = labkit.ui.view.draw(axBottom, 'xy', x, y, labels, opts); + if ~info.ok + addLog('Bottom plot skipped: invalid X/Y.'); + return; + end + addLog(sprintf('Bottom plot: %s vs %s, n=%d', info.yName, info.xName, numel(info.x))); + end + + function refreshCompare() + if isempty(S.curves) + txtQct.Value = ''; + txtQcv.Value = ''; + txtDiff.Value = ''; + txtRel.Value = ''; + txtDtErr.Value = ''; + return; + end + + c = S.curves(S.currentCurve); + opts = struct(); + opts.mode = ddMode.Value; + opts.scanRate = S.scanRate; + opts.area_cm2 = edArea.Value; + R = cscWorkflow("computeCSC", c, opts); + + if ~R.ok + txtQct.Value = R.message; + txtQcv.Value = R.message; + txtDiff.Value = '-'; + txtRel.Value = '-'; + txtDtErr.Value = '-'; + if isfield(R, 'logMessage') && ~isempty(R.logMessage) + addLog(R.logMessage); + end + return; + end + + txtQct.Value = formatChargeAndCSC(R.Qct, R.area_cm2); + txtQcv.Value = formatChargeAndCSC(R.Qcv, R.area_cm2); + txtDiff.Value = formatChargeAndCSC(R.diff_C, R.area_cm2); + txtRel.Value = sprintf('%.6f %%', R.rel_pct); + txtDtErr.Value = sprintf('%.6e s', R.dtErr); + + clearTrim(axTop); + clearTrim(axBottom); + + if cbTopTrim.Value && strcmp(ddTopY.Value,'Im') + [xTop, ~, ~, ~] = labkit.dta.getCurveXY(c, ddTopX.Value, ddTopY.Value); + if numel(xTop) == numel(R.IcathDisp) + hold(axTop,'on'); + plot(axTop, xTop, R.IcathDisp, 'Color',[0.1 0.6 0.1], ... + 'LineWidth',1.0,'Tag','trimCath'); + plot(axTop, xTop, R.IanodDisp, 'Color',[0.8 0.3 0.1], ... + 'LineWidth',1.0,'Tag','trimAnod'); + hold(axTop,'off'); + end + end + + if cbBotTrim.Value && strcmp(ddBotY.Value,'Im') + [xBot, ~, ~, ~] = labkit.dta.getCurveXY(c, ddBotX.Value, ddBotY.Value); + if numel(xBot) == numel(R.IcathDisp) + hold(axBottom,'on'); + plot(axBottom, xBot, R.IcathDisp, 'Color',[0.1 0.6 0.1], ... + 'LineWidth',1.0,'Tag','trimCath'); + plot(axBottom, xBot, R.IanodDisp, 'Color',[0.8 0.3 0.1], ... + 'LineWidth',1.0,'Tag','trimAnod'); + hold(axBottom,'off'); + end + end + + addLog(sprintf(['Compare [%s]: Qct=%.6e C, Qcv=%.6e C, ', ... + 'rel=%.6f %%, maxdt=%.3e s'], ... + ddMode.Value, R.Qct, R.Qcv, R.rel_pct, R.dtErr)); + + if isnan(R.area_cm2) + lblStatus.Text = 'Charge shown (area not set)'; + else + lblStatus.Text = sprintf('CSC normalized by %.6g cm^2', R.area_cm2); + end + end + + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end + +end + +%% App-local formatting and plot cleanup + +function s = formatChargeAndCSC(Q, area_cm2) + if isnan(area_cm2) || area_cm2 <= 0 + s = sprintf('%.12e C', Q); + else + CSC_mC_cm2 = 1e3 * Q / area_cm2; % C -> mC/cm^2 + s = sprintf('%.12e C | %.12e mC/cm^2', Q, CSC_mC_cm2); + end +end + +function clearTrim(ax) + delete(findobj(ax,'Tag','trimCath')); + delete(findobj(ax,'Tag','trimAnod')); +end + +function setDropdownValueIfExists(dd, valueText) + if any(strcmp(dd.Items, valueText)) + dd.Value = valueText; + elseif ~isempty(dd.Items) + dd.Value = dd.Items{1}; + end +end + +%% App-local analysis +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/private/runChronoOverlayApp.m b/apps/electrochem/private/runChronoOverlayApp.m new file mode 100644 index 0000000..a1c5873 --- /dev/null +++ b/apps/electrochem/private/runChronoOverlayApp.m @@ -0,0 +1,518 @@ +% App-owned runner extracted from labkit_ChronoOverlay_app.m. Expected caller: labkit_ChronoOverlay_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runChronoOverlayApp(debugLog) +%RUNCHRONOOVERLAYAPP Build and run the app body. + + S = struct(); + S.session = labkit.dta.makeSession('chrono_overlay'); + S.items = S.session.items; + + workbenchOpts = struct(); + workbenchOpts.rightTitle = 'Overlay Plots'; + workbenchOpts.rightGridSize = [2 1]; + workbenchOpts.rightRowHeight = {'1x', '1x'}; + workbenchOpts.rightRowSpacing = 10; + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Gamry Multi-DTA Plot Export GUI', ... + 'position', [80 60 1480 900], ... + 'leftWidth', 340, ... + 'options', workbenchOpts)); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + right = ui.rightGrid; + + fileCallbacks = struct(); + fileCallbacks.onOpenFiles = @onOpenFiles; + fileCallbacks.onOpenFolder = @onOpenFolder; + fileCallbacks.onRemoveSelected = @onRemoveSelected; + fileCallbacks.onClearAll = @onClearAll; + fileCallbacks.onExport = @onExportCSV; + fileCallbacks.onSelectFile = @(~,~) refreshPlots(); + fileLabels = struct( ... + 'panelTitle', 'Files', ... + 'openFiles', 'Open DTA file(s)', ... + 'openFolder', 'Open folder recursively', ... + 'removeSelected', 'Remove selected', ... + 'clearAll', 'Clear all', ... + 'export', 'Export curves CSV', ... + 'loadedText', 'No files loaded'); + fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... + struct('showRemoveSelected', true, 'multiselect', 'on')); + lbFiles = fileUi.listbox; + txtLoaded = fileUi.loadedText; + + plotOptionsUi = labkit.ui.view.panel(layFA, 'plotOptions', 4, 2); + gp = plotOptionsUi.grid; + + [~, ddXAxis] = labkit.ui.view.form(gp, 'dropdown', 'X axis:', ... + 'Items', {'Time (s)', 'Time (ms)', 'Sample #'}, ... + 'Value', 'Time (s)', ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + + [~, edLineWidth] = labkit.ui.view.form(gp, 'spinner', 'Line width:', ... + 'Value', 1.3, ... + 'Limits', [0.1 10], ... + 'Step', 0.1, ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + + cbLegend = uicheckbox(gp, ... + 'Text', 'Show file-name legend', ... + 'Value', true, ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + cbLegend.Layout.Row = 3; + cbLegend.Layout.Column = [1 2]; + + cbGrid = uicheckbox(gp, ... + 'Text', 'Show grid', ... + 'Value', true, ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + cbGrid.Layout.Row = 4; + cbGrid.Layout.Column = [1 2]; + + infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... + 'Usage:', ... + '1. Open multiple .DTA files.', ... + '2. Curves are aligned to the center of the blank time between cathodic and anodic phases.', ... + '3. Voltage and current curves will be overlaid.', ... + '4. Export CSV columns as: TimeGapCenterAligned_s, V_*, I_*.', ... + '5. If files have different time grids, export uses a merged aligned-time axis with interpolation.' ... + }); + txtInfo = infoUi.textArea; + + logUi = labkit.ui.view.panel(layLog, 'log', 1); + txtLog = logUi.textArea; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('Chrono overlay debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + axV = labkit.ui.view.axes(right, 1, 'Voltage', 'Time (s)', 'Vf (V)'); + axI = labkit.ui.view.axes(right, 2, 'Current', 'Time (s)', 'Im (A)'); + %% App callbacks, session actions, refresh, and export + function onOpenFiles(~, ~) + [f, p] = uigetfile( ... + {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + 'Select one or more Gamry DTA files', ... + 'MultiSelect', 'on'); + if isequal(f, 0) + addLog('Open cancelled.'); + return; + end + + if ischar(f) || isstring(f) + f = {char(f)}; + end + + filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); + loadFiles(filepaths); + end + + function onOpenFolder(~, ~) + folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); + if isequal(folder, 0) + addLog('Folder selection cancelled.'); + return; + end + + filepaths = labkit.dta.findFiles(folder); + if isempty(filepaths) + addLog(sprintf('No DTA files found under: %s', folder)); + uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); + return; + end + + addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); + loadFiles(filepaths); + end + + function loadFiles(filepaths) + if isempty(filepaths) + return; + end + + callbacks = struct(); + callbacks.onAdded = @(~, ~) []; + callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); + callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); + [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); + postProcessAddedItems(report.added); + S.items = S.session.items; + + refreshFileList(); + refreshPlots(); + + if ~isempty(report.failed) + firstError = report.failed(1); + uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); + end + end + + function postProcessAddedItems(filepaths) + for iFile = 1:numel(filepaths) + idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); + if isempty(idx) + continue; + end + + item = S.session.items(idx); + [item, alignMsg] = chronoOverlayWorkflow("alignByPulseGap", item); + S.session.items(idx) = item; + addLog(alignMsg); + + for ii = 1:numel(item.logmsg) + addLog(item.logmsg{ii}); + end + addLog(sprintf('%s: %s', item.name, item.message)); + addLog(sprintf('Loaded: %s', filepaths{iFile})); + end + end + + function onRemoveSelected(~, ~) + if isempty(S.items) || isempty(lbFiles.Value) + return; + end + callbacks = struct(); + callbacks.onRemoved = @(name, ~) addLog(sprintf('Removed: %s', name)); + [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, lbFiles.Value, callbacks); + S.items = S.session.items; + refreshFileList(); + refreshPlots(); + end + + function onClearAll(~, ~) + S.session = labkit.dta.makeSession('chrono_overlay'); + S.items = S.session.items; + refreshFileList(); + refreshPlots(); + addLog('Cleared all files.'); + end + + function refreshFileList() + if isempty(S.items) + labkit.ui.view.update(lbFiles, 'listItems', {}); + txtLoaded.Value = 'No files loaded'; + return; + end + labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); + txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); + end + + function refreshPlots() + if isempty(S.items) + plotVTIT(axV, axI, struct([]), plotOptions()); + return; + end + + items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); + if isempty(items) + cla(axV); + cla(axI); + return; + end + + plotVTIT(axV, axI, items, plotOptions()); + end + + function onExportCSV(~, ~) + if isempty(S.items) + uialert(fig, 'No files loaded.', 'Export'); + return; + end + + items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); + if isempty(items) + uialert(fig, 'No files selected for export.', 'Export'); + return; + end + + [f, p] = uiputfile('gamry_overlay_curves.csv', 'Save overlay curves CSV'); + if isequal(f, 0) + return; + end + + T = chronoOverlayWorkflow("buildOverlayExportTable", items); + out = fullfile(p, f); + writetable(T, out); + addLog(sprintf('Exported CSV: %s', out)); + end + + function opts = plotOptions() + opts = struct(); + opts.xAxis = ddXAxis.Value; + opts.lineWidth = edLineWidth.Value; + opts.showGrid = cbGrid.Value; + opts.showLegend = cbLegend.Value; + end + + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end +end + +%% App-local analysis +function [item, msg] = alignByPulseGap(item) + t = chronoTime(item); + if isempty(t) + error('Chrono item has no time vector.'); + end + + pulseMsg = ''; + if isfield(item, 'pulseMessage') + pulseMsg = item.pulseMessage; + elseif isfield(item, 'pulse') && isfield(item.pulse, 'message') + pulseMsg = item.pulse.message; + end + + pulse = emptyPulse(); + if isfield(item, 'pulse') + pulse = item.pulse; + end + + if isfield(item, 'name') + itemName = item.name; + else + itemName = ''; + end + + if isfield(pulse, 'ok') && pulse.ok + alignTime = 0.5 * (pulse.gap_start + pulse.gap_end); + if isfinite(alignTime) + item.alignTime = alignTime; + item.tAligned = t - alignTime; + item.alignTime_s = item.alignTime; + item.tAligned_s = item.tAligned; + msg = sprintf('%s: aligned to cathodic/anodic blank center at %.9g s (gap %.9g to %.9g s, %s).', ... + itemName, alignTime, pulse.gap_start, pulse.gap_end, pulse.method); + return; + end + + item.alignTime = t(1); + item.tAligned = t - item.alignTime; + item.alignTime_s = item.alignTime; + item.tAligned_s = item.tAligned; + msg = sprintf('%s: blank center not found, fallback to first sample (%s).', itemName, pulseMsg); + return; + end + + item.alignTime = t(1); + item.tAligned = t - item.alignTime; + item.alignTime_s = item.alignTime; + item.tAligned_s = item.tAligned; + msg = sprintf('%s: pulse gap not found, fallback to first sample (%s).', itemName, pulseMsg); +end + +%% App-local export +function T = buildOverlayExportTable(items) + timeUnion = []; + for i = 1:numel(items) + timeUnion = [timeUnion; chronoAlignedTime(items(i))]; %#ok + end + timeUnion = unique(timeUnion); + timeUnion = sort(timeUnion); + + T = table(timeUnion, 'VariableNames', {'TimeGapCenterAligned_s'}); + for i = 1:numel(items) + safeName = sanitizeFieldName(items(i).name); + vName = ['V_' safeName]; + iName = ['I_' safeName]; + + tAligned = chronoAlignedTime(items(i)); + Vf = chronoVoltage(items(i)); + Im = chronoCurrent(items(i)); + if numel(tAligned) >= 2 + vData = interp1(tAligned, Vf, timeUnion, 'linear', NaN); + iData = interp1(tAligned, Im, timeUnion, 'linear', NaN); + else + vData = NaN(size(timeUnion)); + iData = NaN(size(timeUnion)); + end + + T.(vName) = vData; + T.(iName) = iData; + end +end + +%% App-local plotting +function plotVTIT(axV, axI, items, opts) + if nargin < 4 + opts = struct(); + end + if ~isfield(opts, 'xAxis') + opts.xAxis = 'Time (s)'; + end + if ~isfield(opts, 'lineWidth') + opts.lineWidth = 1.3; + end + if ~isfield(opts, 'showGrid') + opts.showGrid = true; + end + if ~isfield(opts, 'showLegend') + opts.showLegend = true; + end + + cla(axV); + cla(axI); + + if isempty(items) + title(axV, 'Voltage'); + title(axI, 'Current'); + xlabel(axV, 'Blank-Center Aligned Time (s)'); + xlabel(axI, 'Blank-Center Aligned Time (s)'); + ylabel(axV, 'Vf (V)'); + ylabel(axI, 'Im (A)'); + return; + end + + cmap = lines(numel(items)); + hold(axV, 'on'); + hold(axI, 'on'); + + labels = cell(1, numel(items)); + for k = 1:numel(items) + item = items(k); + x = chooseX(item, opts.xAxis); + plot(axV, x, chronoVoltage(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); + plot(axI, x, chronoCurrent(item), 'LineWidth', opts.lineWidth, 'Color', cmap(k, :)); + labels{k} = char(item.name); + end + + hold(axV, 'off'); + hold(axI, 'off'); + + xlabelText = axisLabel(opts.xAxis); + xlabel(axV, xlabelText); + xlabel(axI, xlabelText); + ylabel(axV, 'Vf (V)'); + ylabel(axI, 'Im (A)'); + title(axV, sprintf('Voltage Overlay (%d file%s)', numel(items), pluralS(numel(items)))); + title(axI, sprintf('Current Overlay (%d file%s)', numel(items), pluralS(numel(items)))); + + if opts.showGrid + grid(axV, 'on'); + grid(axI, 'on'); + else + grid(axV, 'off'); + grid(axI, 'off'); + end + + if opts.showLegend + legend(axV, labels, 'Interpreter', 'none', 'Location', 'best'); + legend(axI, labels, 'Interpreter', 'none', 'Location', 'best'); + else + legend(axV, 'off'); + legend(axI, 'off'); + end +end + +%% Small app-local utilities +function t = chronoTime(item) + if isfield(item, 't') && ~isempty(item.t) + t = item.t; + elseif isfield(item, 't_s') && ~isempty(item.t_s) + t = item.t_s; + else + t = []; + end + t = t(:); +end + +function t = chronoAlignedTime(item) + if isfield(item, 'tAligned') && ~isempty(item.tAligned) + t = item.tAligned(:); + elseif isfield(item, 'tAligned_s') && ~isempty(item.tAligned_s) + t = item.tAligned_s(:); + else + t = []; + end +end + +function v = chronoVoltage(item) + if isfield(item, 'Vf') && ~isempty(item.Vf) + v = item.Vf(:); + elseif isfield(item, 'Vf_V') && ~isempty(item.Vf_V) + v = item.Vf_V(:); + else + v = []; + end +end + +function i = chronoCurrent(item) + if isfield(item, 'Im') && ~isempty(item.Im) + i = item.Im(:); + elseif isfield(item, 'Im_A') && ~isempty(item.Im_A) + i = item.Im_A(:); + else + i = []; + end +end + +function x = chooseX(item, mode) + switch mode + case 'Time (ms)' + x = 1e3 * chronoAlignedTime(item); + case 'Sample #' + x = samplePoint(item); + otherwise + x = chronoAlignedTime(item); + end +end + +function pt = samplePoint(item) + if isfield(item, 'pt') && ~isempty(item.pt) + pt = item.pt(:); + else + pt = (0:numel(chronoAlignedTime(item))-1).'; + end +end + +function txt = axisLabel(mode) + switch mode + case 'Time (ms)' + txt = 'Blank-Center Aligned Time (ms)'; + case 'Sample #' + txt = 'Sample #'; + otherwise + txt = 'Blank-Center Aligned Time (s)'; + end +end + +function s = pluralS(n) + if n == 1 + s = ''; + else + s = 's'; + end +end + +function out = sanitizeFieldName(txt) + out = matlab.lang.makeValidName(txt); +end + +function pulse = emptyPulse() + pulse = struct( ... + 'ok', false, ... + 'method', '-', ... + 'message', '', ... + 'cath_start', NaN, ... + 'cath_end', NaN, ... + 'anod_start', NaN, ... + 'anod_end', NaN, ... + 'Ic_nominal', NaN, ... + 'Ia_nominal', NaN, ... + 'pre_start', NaN, ... + 'pre_end', NaN, ... + 'gap_start', NaN, ... + 'gap_end', NaN, ... + 'post_start', NaN, ... + 'post_end', NaN); + + pulse.cath = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); + pulse.anod = struct('start_s', NaN, 'end_s', NaN, 'current_A', NaN); + pulse.gap = struct('start_s', NaN, 'end_s', NaN, 'center_s', NaN); +end diff --git a/apps/electrochem/private/runEISApp.m b/apps/electrochem/private/runEISApp.m new file mode 100644 index 0000000..d9ee3c2 --- /dev/null +++ b/apps/electrochem/private/runEISApp.m @@ -0,0 +1,509 @@ +% App-owned runner extracted from labkit_EIS_app.m. Expected caller: labkit_EIS_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runEISApp(debugLog) +%RUNEISAPP Build and run the app body. + + S = struct(); + S.session = labkit.dta.makeSession('eis_overlay'); + S.items = S.session.items; + + axisItems = { ... + 'Freq (Hz)', ... + 'log10(Freq)', ... + 'Time (s)', ... + 'Point #', ... + 'Zreal (ohm)', ... + 'Zimag (ohm)', ... + '-Zimag (ohm)', ... + 'Zmod (ohm)', ... + 'Zphz (deg)', ... + 'Idc (A)', ... + 'Vdc (V)'}; + + workbenchOpts = struct(); + workbenchOpts.rightTitle = 'Plot'; + workbenchOpts.rightGridSize = [1 1]; + workbenchOpts.rightRowHeight = {'1x'}; + workbenchOpts.rightRowSpacing = 8; + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Gamry EIS Multi-DTA Plot GUI', ... + 'position', [80 60 1500 900], ... + 'leftWidth', 360, ... + 'options', workbenchOpts)); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + right = ui.rightGrid; + + fileCallbacks = struct(); + fileCallbacks.onOpenFiles = @onOpenFiles; + fileCallbacks.onOpenFolder = @onOpenFolder; + fileCallbacks.onRemoveSelected = @onRemoveSelected; + fileCallbacks.onClearAll = @onClearAll; + fileCallbacks.onExport = @onExportCSV; + fileCallbacks.onSelectFile = @(~,~) refreshPlot(); + fileLabels = struct( ... + 'panelTitle', 'Files', ... + 'openFiles', 'Open DTA file(s)', ... + 'openFolder', 'Open folder recursively', ... + 'removeSelected', 'Remove selected', ... + 'clearAll', 'Clear all', ... + 'export', 'Export current plot CSV', ... + 'loadedText', 'No files loaded'); + fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks, ... + struct('showRemoveSelected', true, 'multiselect', 'on')); + lbFiles = fileUi.listbox; + txtLoaded = fileUi.loadedText; + + plotOptionsUi = labkit.ui.view.panel(layFA, 'plotOptions', 8, 2); + gp = plotOptionsUi.grid; + + [~, ddX] = labkit.ui.view.form(gp, 'dropdown', 'X axis:', ... + 'Items', axisItems, ... + 'Value', 'Zreal (ohm)', ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + + [~, ddY] = labkit.ui.view.form(gp, 'dropdown', 'Y axis:', ... + 'Items', axisItems, ... + 'Value', '-Zimag (ohm)', ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + + [~, edLineWidth] = labkit.ui.view.form(gp, 'spinner', 'Line width:', ... + 'Value', 1.4, ... + 'Limits', [0.1 10], ... + 'Step', 0.1, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + + [~, edMarkerSize] = labkit.ui.view.form(gp, 'spinner', 'Marker size:', ... + 'Value', 6, ... + 'Limits', [1 20], ... + 'Step', 1, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + + cbMarkers = uicheckbox(gp, ... + 'Text', 'Show markers', ... + 'Value', true, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + cbMarkers.Layout.Row = 5; + cbMarkers.Layout.Column = [1 2]; + + cbLogX = uicheckbox(gp, ... + 'Text', 'Log X', ... + 'Value', false, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + cbLogX.Layout.Row = 6; + cbLogX.Layout.Column = [1 2]; + + cbLogY = uicheckbox(gp, ... + 'Text', 'Log Y', ... + 'Value', false, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + cbLogY.Layout.Row = 7; + cbLogY.Layout.Column = [1 2]; + + row8 = uigridlayout(gp, [1 2]); + row8.Layout.Row = 8; + row8.Layout.Column = [1 2]; + row8.ColumnWidth = {'1x', '1x'}; + row8.RowHeight = {'fit'}; + row8.Padding = [0 0 0 0]; + row8.ColumnSpacing = 8; + + cbLegend = uicheckbox(row8, ... + 'Text', 'Legend', ... + 'Value', true, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + cbGrid = uicheckbox(row8, ... + 'Text', 'Grid', ... + 'Value', true, ... + 'ValueChangedFcn', @(~,~) refreshPlot()); + + infoUi = labkit.ui.view.panel(laySR, 'text', 'Usage', 1, { ... + 'Usage:', ... + '1. Open one or more EIS .DTA files containing ZCURVE.', ... + '2. Choose any X and Y axis combination.', ... + '3. Use Zreal vs -Zimag for a Nyquist plot.', ... + '4. Use Freq vs Zmod or Zphz for Bode-style plots.', ... + '5. CSV export writes one shared row index with X/Y pairs per file.'}); + txtInfo = infoUi.textArea; + + logUi = labkit.ui.view.panel(layLog, 'log', 1); + txtLog = logUi.textArea; + + ax = labkit.ui.view.axes(right, 1, 'EIS Overlay', 'Zreal (ohm)', '-Zimag (ohm)'); + + txtSummary = uitextarea(laySR, 'Editable', 'off'); + labkit.ui.view.place(txtSummary, laySR, 2); + txtSummary.Value = {'No files loaded.'}; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('EIS debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + %% App callbacks, session actions, refresh, and export + function onOpenFiles(~, ~) + [f, p] = uigetfile( ... + {'*.DTA;*.dta', 'Gamry DTA (*.DTA)'; '*.*', 'All files'}, ... + 'Select one or more Gamry EIS DTA files', ... + 'MultiSelect', 'on'); + if isequal(f, 0) + addLog('Open cancelled.'); + return; + end + + if ischar(f) || isstring(f) + f = {char(f)}; + end + + filepaths = cellfun(@(name) fullfile(p, name), f, 'UniformOutput', false); + loadFiles(filepaths); + end + + function onOpenFolder(~, ~) + folder = uigetdir(pwd, 'Select a folder to recursively scan for .DTA files'); + if isequal(folder, 0) + addLog('Folder selection cancelled.'); + return; + end + + filepaths = labkit.dta.findFiles(folder); + if isempty(filepaths) + addLog(sprintf('No DTA files found under: %s', folder)); + uialert(fig, sprintf('No .DTA files found under:\n%s', folder), 'No files found'); + return; + end + + addLog(sprintf('Found %d DTA file(s) under %s', numel(filepaths), folder)); + loadFiles(filepaths); + end + + function loadFiles(filepaths) + if isempty(filepaths) + return; + end + + callbacks = struct(); + callbacks.onAdded = @onAddedDTA; + callbacks.onSkipped = @(filepath) addLog(sprintf('Skipped already loaded: %s', filepath)); + callbacks.onFailed = @(filepath, message) addLog(sprintf('Failed: %s | %s', filepath, message)); + [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "eis", callbacks); + S.items = S.session.items; + + refreshFileList(); + refreshPlot(); + + if ~isempty(report.failed) + firstError = report.failed(1); + uialert(fig, sprintf('Failed to load:\n%s\n\n%s', firstError.filepath, firstError.message), 'Load error'); + end + end + + function onAddedDTA(filepath, item) + for ii = 1:numel(item.logmsg) + addLog(item.logmsg{ii}); + end + addLog(sprintf('%s: %s', item.name, item.message)); + addLog(sprintf('Loaded: %s', filepath)); + end + + function onRemoveSelected(~, ~) + if isempty(S.items) || isempty(lbFiles.Value) + return; + end + callbacks = struct(); + callbacks.onRemoved = @(name, ~) addLog(sprintf('Removed: %s', name)); + [S.session, ~] = labkit.dta.removeSelectedItemsFromSession(S.session, lbFiles.Value, callbacks); + S.items = S.session.items; + refreshFileList(); + refreshPlot(); + end + + function onClearAll(~, ~) + S.session = labkit.dta.makeSession('eis_overlay'); + S.items = S.session.items; + refreshFileList(); + refreshPlot(); + addLog('Cleared all files.'); + end + + function refreshFileList() + if isempty(S.items) + labkit.ui.view.update(lbFiles, 'listItems', {}); + txtLoaded.Value = 'No files loaded'; + return; + end + labkit.ui.view.update(lbFiles, 'listItems', {S.items.name}); + txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); + end + + function refreshPlot() + cla(ax); + ax.XScale = ternary(cbLogX.Value, 'log', 'linear'); + ax.YScale = ternary(cbLogY.Value, 'log', 'linear'); + axis(ax, 'normal'); + + if isempty(S.items) + title(ax, 'EIS Overlay'); + xlabel(ax, labelForAxis(ddX.Value)); + ylabel(ax, labelForAxis(ddY.Value)); + txtSummary.Value = {'No files loaded.'}; + return; + end + + items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); + if isempty(items) + txtSummary.Value = {'No files selected.'}; + return; + end + + plotOpts = struct(); + plotOpts.xName = ddX.Value; + plotOpts.yName = ddY.Value; + plotOpts.logX = cbLogX.Value; + plotOpts.logY = cbLogY.Value; + plotOpts.lineWidth = edLineWidth.Value; + plotOpts.markerSize = edMarkerSize.Value; + plotOpts.showMarkers = cbMarkers.Value; + plotOpts.showLegend = cbLegend.Value; + plotOpts.showGrid = cbGrid.Value; + plotOverlay(ax, items, plotOpts); + + txtSummary.Value = buildSummary(items); + end + + function onExportCSV(~, ~) + items = labkit.dta.selectSessionItems(S.session, lbFiles.Value); + if isempty(items) + uialert(fig, 'No files selected for export.', 'Export'); + return; + end + + [f, p] = uiputfile('gamry_eis_plot_export.csv', 'Save current X/Y plot CSV'); + if isequal(f, 0) + return; + end + + T = eisWorkflow("buildExportTable", items, ddX.Value, ddY.Value, cbLogX.Value, cbLogY.Value); + out = fullfile(p, f); + writetable(T, out); + addLog(sprintf('Exported CSV: %s', out)); + end + + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end +end + +%% App-local plotting and summary helpers +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/private/runVTResistanceApp.m b/apps/electrochem/private/runVTResistanceApp.m new file mode 100644 index 0000000..745474c --- /dev/null +++ b/apps/electrochem/private/runVTResistanceApp.m @@ -0,0 +1,992 @@ +% App-owned runner extracted from labkit_VTResistance_app.m. Expected caller: labkit_VTResistance_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runVTResistanceApp(debugLog) +%RUNVTRESISTANCEAPP Build and run the app body. + + S = struct(); + S.session = labkit.dta.makeSession('vt_resistance'); + S.items = S.session.items; + S.current = []; + + ui = labkit.ui.app.createShell(struct( ... + 'title', 'Gamry VT Steady Resistance GUI', ... + 'position', [40 30 1680 980], ... + 'leftWidth', 430, ... + 'options', struct('rightKind', 'dualPlot'))); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + fileCallbacks = struct(); + fileCallbacks.onOpenFiles = @onOpenFiles; + fileCallbacks.onOpenFolder = @onOpenFolder; + fileCallbacks.onClearAll = @(~,~) clearAllFiles(); + fileCallbacks.onExport = @(~,~) exportResultsCSV(); + fileCallbacks.onSelectFile = @(~,~) onSelectFile(); + fileLabels = struct( ... + 'panelTitle', 'Files', ... + 'openFiles', 'Open DTA file(s)', ... + 'openFolder', 'Open folder recursively', ... + 'clearAll', 'Clear all', ... + 'export', 'Export results CSV', ... + 'loadedText', 'No files loaded'); + fileUi = labkit.ui.view.panel(layFA, 'files', fileLabels, fileCallbacks); + lbFiles = fileUi.listbox; + txtLoaded = fileUi.loadedText; + + settingsUi = labkit.ui.view.section(layFA, 'Analysis Settings', 2, [3 2]); + gs = settingsUi.grid; + + uilabel(gs,'Text','Pulse detection:','HorizontalAlignment','right'); + ddPulseMode = uidropdown(gs, ... + 'Items',{'Metadata first, then auto','Metadata only','Auto from Im only'}, ... + 'Value','Metadata first, then auto', ... + 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + ddPulseMode.Layout.Row = 1; + ddPulseMode.Layout.Column = 2; + + uilabel(gs,'Text','Steady window:','HorizontalAlignment','right'); + ddSteadyWindow = uidropdown(gs, ... + 'Items',{'Full pulse median','Center 60% median'}, ... + 'Value','Full pulse median', ... + 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + ddSteadyWindow.Layout.Row = 2; + ddSteadyWindow.Layout.Column = 2; + + uilabel(gs,'Text','Resistance voltage:','HorizontalAlignment','right'); + ddVoltageMode = uidropdown(gs, ... + 'Items',{'Baseline-corrected dV/I','Raw Vf/I'}, ... + 'Value','Baseline-corrected dV/I', ... + 'ValueChangedFcn',@(~,~) analyzeCurrentFile()); + ddVoltageMode.Layout.Row = 3; + ddVoltageMode.Layout.Column = 2; + + actionUi = labkit.ui.view.section(layFA, 'Plot / Debug', 3, [2 3]); + ga = actionUi.grid; + + btnReanalyze = uibutton(ga,'Text','Re-analyze file','ButtonPushedFcn',@(~,~) analyzeCurrentFile()); + btnReanalyze.Layout.Row = 1; btnReanalyze.Layout.Column = 1; + btnRefresh = uibutton(ga,'Text','Refresh plots','ButtonPushedFcn',@(~,~) refreshPlots()); + btnRefresh.Layout.Row = 1; btnRefresh.Layout.Column = 2; + btnSwap = uibutton(ga,'Text','Swap top / bottom','ButtonPushedFcn',@(~,~) swapPlots()); + btnSwap.Layout.Row = 1; btnSwap.Layout.Column = 3; + + btnReset = uibutton(ga,'Text','Reset axes','ButtonPushedFcn',@(~,~) resetAxes()); + btnReset.Layout.Row = 2; btnReset.Layout.Column = 1; + cbShowMarkers = uicheckbox(ga,'Text','Show markers','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); + cbShowMarkers.Layout.Row = 2; cbShowMarkers.Layout.Column = 2; + cbShowShading = uicheckbox(ga,'Text','Shade windows','Value',true,'ValueChangedFcn',@(~,~) refreshPlots()); + cbShowShading.Layout.Row = 2; cbShowShading.Layout.Column = 3; + + infoUi = labkit.ui.view.section(laySR, 'Current File Summary', 1, [13 2]); + gi = infoUi.grid; + + S.txtControlMode = labkit.ui.view.form(gi, 'info', 1, 'Control mode:'); + S.txtDetect = labkit.ui.view.form(gi, 'info', 2, 'Detection:'); + S.txtWindow = labkit.ui.view.form(gi, 'info', 3, 'Window:'); + S.txtCathIV = labkit.ui.view.form(gi, 'info', 4, 'Cathodic I / Vss:'); + S.txtAnodIV = labkit.ui.view.form(gi, 'info', 5, 'Anodic I / Vss:'); + S.txtCathBase = labkit.ui.view.form(gi, 'info', 6, 'Cathodic baseline:'); + S.txtAnodBase = labkit.ui.view.form(gi, 'info', 7, 'Anodic baseline:'); + S.txtCathBaseWin = labkit.ui.view.form(gi, 'info', 8, 'Cath baseline window:'); + S.txtAnodBaseWin = labkit.ui.view.form(gi, 'info', 9, 'Anod baseline window:'); + S.txtCathR = labkit.ui.view.form(gi, 'info', 10, 'Cathodic R:'); + S.txtAnodR = labkit.ui.view.form(gi, 'info', 11, 'Anodic R:'); + S.txtAvgR = labkit.ui.view.form(gi, 'info', 12, 'Average R:'); + S.txtStatus = labkit.ui.view.form(gi, 'info', 13, 'Status:'); + + tableUi = labkit.ui.view.panel(laySR, 'table', 'Batch Results', 2, ... + {'File','Ic(A)','Ia(A)','Vc_ss(V)','Va_ss(V)','R_cath(ohm)','R_anod(ohm)','R_avg(ohm)','Detection'}, ... + cell(0,9)); + tbl = tableUi.table; + + logUi = labkit.ui.view.panel(layLog, 'log', 1); + txtLog = logUi.textArea; + + topPlotDefaults = struct('x', 'Time (s)', 'y', 'VT: Vf vs time', 'grid', true); + bottomPlotDefaults = struct('x', 'Time (s)', 'y', 'IT: Im vs time', 'grid', true); + plotControls = labkit.ui.view.panel( ... + ui.topControlsPanel, ... + 'topBottomPlotControls', ... + ui.bottomControlsPanel, ... + {'Time (s)', 'Sample #'}, ... + {'VT: Vf vs time', 'IT: Im vs time'}, ... + topPlotDefaults, ... + bottomPlotDefaults, ... + @(~,~) refreshPlots()); + ddTopX = plotControls.topX; + ddTopY = plotControls.topY; + cbTopGrid = plotControls.topGridCheckbox; + axTop = ui.topAxes; + ddBotX = plotControls.bottomX; + ddBotY = plotControls.bottomY; + cbBotGrid = plotControls.bottomGridCheckbox; + axBottom = ui.bottomAxes; + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('VT resistance debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + %% App callbacks, session actions, refresh, plotting, and export + function onOpenFiles(~,~) + [files,path] = uigetfile({'*.DTA;*.dta','Gamry DTA files (*.DTA)'}, ... + 'Select Gamry DTA file(s)','MultiSelect','on'); + if isequal(files,0) + return; + end + if ischar(files) + files = {files}; + end + filepaths = cellfun(@(f) fullfile(path,f), files, 'UniformOutput', false); + addFiles(filepaths); + end + + function onOpenFolder(~,~) + folder = uigetdir(pwd,'Select folder containing DTA files'); + if isequal(folder,0) + return; + end + filepaths = labkit.dta.findFiles(folder); + if isempty(filepaths) + uialert(fig,'No .DTA files found in the selected folder.','Open folder'); + return; + end + addFiles(filepaths); + end + + function addFiles(filepaths) + callbacks = struct(); + callbacks.onAdded = @(~, ~) []; + callbacks.onSkipped = @(fp) addLog(['Skipped duplicate: ' fp]); + callbacks.onFailed = @(fp, msg) addLog(sprintf('Failed to load %s: %s', fp, msg)); + [S.session, report] = labkit.dta.addFilesToSession(S.session, filepaths, "chrono", callbacks); + postProcessAddedItems(report.added); + S.items = S.session.items; + if ~isempty(S.items) && isempty(S.current) + S.current = 1; + end + refreshFileList(); + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + end + + function postProcessAddedItems(filepaths) + for iFile = 1:numel(filepaths) + idx = find(strcmp(string({S.session.items.filepath}), string(filepaths{iFile})), 1, 'first'); + if isempty(idx) + continue; + end + item = S.session.items(idx); + for ii = 1:numel(item.logmsg) + addLog(item.logmsg{ii}); + end + item = analyzeItem(item); + S.session.items(idx) = item; + addLog(['Loaded: ' filepaths{iFile}]); + end + end + + function analyzeCurrentFile() + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + refreshResultsSummary(); + refreshPlots(); + return; + end + S.items(S.current) = analyzeItem(S.items(S.current)); + S.session.items = S.items; + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + end + + function item = analyzeItem(item) + opts = struct(); + opts.windowMode = ddSteadyWindow.Value; + opts.voltageMode = ddVoltageMode.Value; + opts.pulseMode = ddPulseMode.Value; + + A = vtResistanceWorkflow("computeResistance", item, opts); + if A.ok + addLog(sprintf('%s: Rc=%.6g ohm, Ra=%.6g ohm, Ravg=%.6g ohm', ... + item.name, A.Rc_abs_ohm, A.Ra_abs_ohm, A.Ravg_abs_ohm)); + elseif isfield(A, 'logOnFailure') && A.logOnFailure + addLog(sprintf('%s: %s', item.name, A.message)); + end + item.analysis = A; + end + + function onSelectFile() + if isempty(lbFiles.Items) + S.current = []; + resetAxesToDefaultState(); + refreshResultsSummary(); + refreshPlots(); + return; + end + + idx = find(strcmp(lbFiles.Items, lbFiles.Value), 1); + if isempty(idx) + S.current = []; + else + S.current = idx; + end + + restoreDefaultPlotSelections(); + resetAxesToDefaultState(); + refreshResultsSummary(); + refreshPlots(); + end + + function clearAllFiles() + S.session = labkit.dta.makeSession('vt_resistance'); + S.items = S.session.items; + S.current = []; + restoreDefaultPlotSelections(); + resetAxesToDefaultState(); + refreshFileList(); + refreshBatchTable(); + refreshResultsSummary(); + refreshPlots(); + addLog('Cleared all files.'); + end + + function refreshFileList() + if isempty(S.items) + labkit.ui.view.update(lbFiles, 'listSelection', {}); + txtLoaded.Value = fileLabels.loadedText; + S.current = []; + return; + end + + names = {S.items.name}; + [~, idx] = labkit.ui.view.update(lbFiles, 'listSelection', names, S.current); + S.current = idx(1); + txtLoaded.Value = sprintf('%d file(s) loaded', numel(S.items)); + end + + function refreshBatchTable() + if isempty(S.items) + tbl.Data = cell(0,9); + return; + end + tbl.Data = vtResistanceWorkflow("buildBatchTableData", S.items); + end + + function refreshResultsSummary() + S.txtControlMode.Value = '-'; + S.txtDetect.Value = '-'; + S.txtWindow.Value = '-'; + S.txtCathIV.Value = '-'; + S.txtAnodIV.Value = '-'; + S.txtCathBase.Value = '-'; + S.txtAnodBase.Value = '-'; + S.txtCathBaseWin.Value = '-'; + S.txtAnodBaseWin.Value = '-'; + S.txtCathR.Value = '-'; + S.txtAnodR.Value = '-'; + S.txtAvgR.Value = '-'; + S.txtStatus.Value = '-'; + + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + return; + end + it = S.items(S.current); + S.txtControlMode.Value = chronoControlModeText(it); + if isempty(it.analysis) || ~it.analysis.ok + if ~isempty(it.analysis) && isfield(it.analysis,'message') + S.txtStatus.Value = it.analysis.message; + else + S.txtStatus.Value = 'No valid analysis'; + end + return; + end + + A = it.analysis; + S.txtDetect.Value = sprintf('%s | %s', A.detectMode, A.detectMsg); + S.txtWindow.Value = sprintf('%s | %s', A.windowMode, A.voltageMode); + S.txtCathIV.Value = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', A.Ic_est_A, A.Vc_ss_V, A.dVc_V); + S.txtAnodIV.Value = sprintf('I=%.6e A | Vss=%.6f V | dV=%.6f V', A.Ia_est_A, A.Va_ss_V, A.dVa_V); + S.txtCathBase.Value = sprintf('%.6f V', A.Vc_baseline_V); + S.txtAnodBase.Value = sprintf('%.6f V', A.Va_baseline_V); + S.txtCathBaseWin.Value = formatDurationUs(A.cathBaselineWindow_s); + S.txtAnodBaseWin.Value = formatDurationUs(A.anodBaselineWindow_s); + S.txtCathR.Value = sprintf('%.6g ohm (signed %.6g)', A.Rc_abs_ohm, A.Rc_ohm); + S.txtAnodR.Value = sprintf('%.6g ohm (signed %.6g)', A.Ra_abs_ohm, A.Ra_ohm); + S.txtAvgR.Value = sprintf('%.6g ohm', A.Ravg_abs_ohm); + S.txtStatus.Value = A.message; + end + + function out = chronoControlModeText(item) + out = 'Unknown chrono control mode'; + if ~isfield(item, 'controlMode') + return; + end + + switch string(item.controlMode) + case "current" + out = 'Current-controlled chrono'; + case "voltage" + out = 'Voltage-controlled chrono'; + otherwise + out = 'Unknown chrono control mode'; + end + end + + function refreshPlots() + labkit.ui.view.draw(axTop, 'clear'); + labkit.ui.view.draw(axBottom, 'clear'); + if isempty(S.items) || isempty(S.current) || S.current < 1 || S.current > numel(S.items) + title(axTop,'Top Plot'); + title(axBottom,'Bottom Plot'); + return; + end + + it = S.items(S.current); + if isempty(it.analysis) || ~it.analysis.ok + title(axTop,'Top Plot'); + title(axBottom,'Bottom Plot'); + text(axTop,0.5,0.5,'No valid analysis','Units','normalized','HorizontalAlignment','center'); + return; + end + A = it.analysis; + plotOneAxis(axTop, A, ddTopX.Value, ddTopY.Value, cbTopGrid.Value); + plotOneAxis(axBottom, A, ddBotX.Value, ddBotY.Value, cbBotGrid.Value); + end + + function plotOneAxis(ax, A, xChoice, yChoice, showGrid) + if strcmp(xChoice,'Sample #') + x = A.pt; + xlab = 'Sample #'; + cathStartX = interp1Safe(A.t, A.pt, A.pulse.cath_start); + cathEndX = interp1Safe(A.t, A.pt, A.pulse.cath_end); + anodStartX = interp1Safe(A.t, A.pt, A.pulse.anod_start); + anodEndX = interp1Safe(A.t, A.pt, A.pulse.anod_end); + cathBaseStartX = interp1Safe(A.t, A.pt, A.pulse.pre_start); + cathBaseEndX = interp1Safe(A.t, A.pt, A.pulse.pre_end); + anodBaseStartX = interp1Safe(A.t, A.pt, A.anodBaselineStart); + anodBaseEndX = interp1Safe(A.t, A.pt, A.anodBaselineEnd); + cSteadyStartX = interp1Safe(A.t, A.pt, A.cathSteadyStart); + cSteadyEndX = interp1Safe(A.t, A.pt, A.cathSteadyEnd); + aSteadyStartX = interp1Safe(A.t, A.pt, A.anodSteadyStart); + aSteadyEndX = interp1Safe(A.t, A.pt, A.anodSteadyEnd); + else + x = A.t; + xlab = 'Time (s)'; + cathStartX = A.pulse.cath_start; + cathEndX = A.pulse.cath_end; + anodStartX = A.pulse.anod_start; + anodEndX = A.pulse.anod_end; + cathBaseStartX = A.pulse.pre_start; + cathBaseEndX = A.pulse.pre_end; + anodBaseStartX = A.anodBaselineStart; + anodBaseEndX = A.anodBaselineEnd; + cSteadyStartX = A.cathSteadyStart; + cSteadyEndX = A.cathSteadyEnd; + aSteadyStartX = A.anodSteadyStart; + aSteadyEndX = A.anodSteadyEnd; + end + + if startsWith(yChoice,'VT') + plot(ax, x, A.Vf, 'LineWidth',1.25, 'Color',[0 0.4470 0.7410]); + ylab = 'Vf (V vs Ref.)'; + ttl = sprintf('%s | VT | Ravg = %.6g ohm', itName(), A.Ravg_abs_ohm); + hold(ax,'on'); + else + plot(ax, x, A.Im, 'LineWidth',1.25, 'Color',[0.8500 0.3250 0.0980]); + ylab = 'Im (A)'; + ttl = sprintf('%s | IT | Ic %.4g A, Ia %.4g A', itName(), A.Ic_est_A, A.Ia_est_A); + hold(ax,'on'); + end + + if cbShowShading.Value + shadeWindow(ax, cathStartX, cathEndX, [0.90 0.95 1.00], 0.12); + shadeWindow(ax, anodStartX, anodEndX, [1.00 0.94 0.88], 0.12); + shadeWindow(ax, cSteadyStartX, cSteadyEndX, [0.65 0.82 1.00], 0.22); + shadeWindow(ax, aSteadyStartX, aSteadyEndX, [1.00 0.75 0.55], 0.22); + end + if cbShowMarkers.Value + xline(ax, cathStartX, ':', 'Cath start','Color',[0.2 0.4 0.8]); + xline(ax, cathEndX, ':', 'Cath end','Color',[0.2 0.4 0.8]); + xline(ax, anodStartX, ':', 'Anod start','Color',[0.8 0.4 0.2]); + xline(ax, anodEndX, ':', 'Anod end','Color',[0.8 0.4 0.2]); + if startsWith(yChoice,'VT') + addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... + cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX); + else + addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... + cathStartX, cathEndX, anodStartX, anodEndX); + end + end + hold(ax,'off'); + + title(ax, ttl, 'Interpreter','none'); + xlabel(ax, xlab); + ylabel(ax, ylab); + grid(ax, ternary(showGrid,'on','off')); + end + + function nm = itName() + if isempty(S.items) || isempty(S.current) + nm = 'file'; + else + nm = S.items(S.current).name; + end + end + + function swapPlots() + labkit.ui.view.update(plotControls, 'swapPlotSelections'); + refreshPlots(); + end + + function resetAxes() + resetAxesToDefaultState(); + refreshPlots(); + end + + function restoreDefaultPlotSelections() + labkit.ui.view.update(plotControls, 'setPlotSelections', ... + topPlotDefaults, bottomPlotDefaults); + end + + function resetAxesToDefaultState() + labkit.ui.view.draw(axTop, 'reset', 'Top Plot'); + labkit.ui.view.draw(axBottom, 'reset', 'Bottom Plot'); + end + + function exportResultsCSV() + if isempty(S.items) + uialert(fig,'No results to export.','Export'); + return; + end + [f,p] = uiputfile('vt_steady_resistance_results.csv','Save results CSV'); + if isequal(f,0) + return; + end + out = fullfile(p,f); + [ok, msg] = vtResistanceWorkflow("writeResultsCSV", S.items, out); + if ~ok + uialert(fig,msg,'Export'); + return; + end + addLog(['Exported CSV: ' out]); + end + + function addLog(msg) + labkit.ui.view.update(txtLog, 'appendLog', msg); + debugLog.append(msg); + end + +end + +%% App-local analysis +function A = computeResistance(item, opts) +%COMPUTERESISTANCE Compute VT resistance metrics for the VT app. + + if nargin < 2 + opts = struct(); + end + opts = fillResistanceOptions(opts); + + A = struct(); + A.ok = false; + A.message = ''; + A.windowMode = opts.windowMode; + A.voltageMode = opts.voltageMode; + A.logOnFailure = false; + + [curve, okCurve, msgCurve] = mainCurve(item); + if ~okCurve + A.message = msgCurve; + A.logOnFailure = true; + return; + end + + t = labkit.dta.getColumn(curve, 'T'); + Vf = labkit.dta.getColumn(curve, 'Vf'); + Im = labkit.dta.getColumn(curve, 'Im'); + pt = labkit.dta.getColumn(curve, 'Pt'); + if isempty(pt) + pt = (0:numel(t)-1).'; + end + + valid = ~(isnan(t) | isnan(Vf) | isnan(Im)); + t = t(valid); + Vf = Vf(valid); + Im = Im(valid); + pt = pt(valid); + if numel(t) < 5 + A.message = 'Not enough valid T/Vf/Im points.'; + return; + end + + A.t = t; + A.Vf = Vf; + A.Im = Im; + A.pt = pt; + + meta = struct(); + if isfield(item, 'meta') + meta = item.meta; + end + [pulse, pulseMsg] = labkit.dta.detectPulses(t, Im, meta, opts.pulseMode); + A.pulse = pulse; + A.detectMode = pulse.method; + A.detectMsg = pulseMsg; + if ~pulse.ok + A.message = pulseMsg; + A.logOnFailure = true; + return; + end + + [cStart, cEnd] = selectSteadyWindow(pulse.cath_start, pulse.cath_end, A.windowMode); + [aStart, aEnd] = selectSteadyWindow(pulse.anod_start, pulse.anod_end, A.windowMode); + cathMask = t >= cStart & t <= cEnd; + anodMask = t >= aStart & t <= aEnd; + if nnz(cathMask) < 2 || nnz(anodMask) < 2 + A.message = 'Steady windows are too short after pulse detection.'; + return; + end + + A.cathMask = cathMask; + A.anodMask = anodMask; + A.cathSteadyStart = cStart; + A.cathSteadyEnd = cEnd; + A.anodSteadyStart = aStart; + A.anodSteadyEnd = aEnd; + + A.Ic_est_A = median(Im(cathMask), 'omitnan'); + A.Ia_est_A = median(Im(anodMask), 'omitnan'); + A.Vc_ss_V = median(Vf(cathMask), 'omitnan'); + A.Va_ss_V = median(Vf(anodMask), 'omitnan'); + + A.cathBaselineStart = pulse.pre_start; + A.cathBaselineEnd = pulse.pre_end; + A.anodBaselineStart = pulse.post_start; + A.anodBaselineEnd = pulse.post_end; + [A.Vc_baseline_V, A.cathBaselineWindow_s] = estimateBaseline( ... + t, Vf, pulse.pre_start, pulse.pre_end, 0); + [A.Va_baseline_V, A.anodBaselineWindow_s] = estimateBaseline( ... + t, Vf, pulse.post_start, pulse.post_end, chooseFinite(A.Vc_baseline_V, 0)); + + A.dVc_V = A.Vc_ss_V - A.Vc_baseline_V; + A.dVa_V = A.Va_ss_V - A.Va_baseline_V; + A.Rc_raw_ohm = safeDivide(A.Vc_ss_V, A.Ic_est_A); + A.Ra_raw_ohm = safeDivide(A.Va_ss_V, A.Ia_est_A); + A.Rc_dV_ohm = safeDivide(A.dVc_V, A.Ic_est_A); + A.Ra_dV_ohm = safeDivide(A.dVa_V, A.Ia_est_A); + + if strcmp(A.voltageMode, 'Raw Vf/I') + A.Rc_ohm = A.Rc_raw_ohm; + A.Ra_ohm = A.Ra_raw_ohm; + else + A.Rc_ohm = A.Rc_dV_ohm; + A.Ra_ohm = A.Ra_dV_ohm; + end + A.Rc_abs_ohm = abs(A.Rc_ohm); + A.Ra_abs_ohm = abs(A.Ra_ohm); + A.Ravg_abs_ohm = mean([A.Rc_abs_ohm, A.Ra_abs_ohm], 'omitnan'); + + A.ok = isfinite(A.Ravg_abs_ohm); + if A.ok + A.message = 'OK'; + else + A.message = 'Resistance could not be computed; check current and pulse detection.'; + A.logOnFailure = true; + end +end + +function opts = fillResistanceOptions(opts) + if ~isfield(opts, 'windowMode') + opts.windowMode = 'Full pulse median'; + end + if ~isfield(opts, 'voltageMode') + opts.voltageMode = 'Baseline-corrected dV/I'; + end + if ~isfield(opts, 'pulseMode') + opts.pulseMode = 'Metadata first, then auto'; + end +end + +%% App-local table/export helpers +function C = buildBatchTableData(items) +%BUILDBATCHTABLEDATA Build VT resistance uitable data. + + C = cell(numel(items), 9); + for i = 1:numel(items) + item = items(i); + C{i, 1} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + C{i, 2} = NaN; + C{i, 3} = NaN; + C{i, 4} = NaN; + C{i, 5} = NaN; + C{i, 6} = NaN; + C{i, 7} = NaN; + C{i, 8} = NaN; + C{i, 9} = 'parse/analyze failed'; + continue; + end + + C{i, 2} = A.Ic_est_A; + C{i, 3} = A.Ia_est_A; + C{i, 4} = A.Vc_ss_V; + C{i, 5} = A.Va_ss_V; + C{i, 6} = A.Rc_abs_ohm; + C{i, 7} = A.Ra_abs_ohm; + C{i, 8} = A.Ravg_abs_ohm; + C{i, 9} = A.detectMode; + end +end + +function T = buildResultsTable(items) +%BUILDRESULTSTABLE Build VT resistance CSV result table. + + file = cell(numel(items), 1); + Ic_A = NaN(numel(items), 1); + Ia_A = NaN(numel(items), 1); + Vc_ss_V = NaN(numel(items), 1); + Va_ss_V = NaN(numel(items), 1); + Vc_baseline_V = NaN(numel(items), 1); + Va_baseline_V = NaN(numel(items), 1); + dVc_V = NaN(numel(items), 1); + dVa_V = NaN(numel(items), 1); + Rc_bc_ohm = NaN(numel(items), 1); + Ra_bc_ohm = NaN(numel(items), 1); + Ravg_bc_ohm = NaN(numel(items), 1); + windowMode = cell(numel(items), 1); + detection = cell(numel(items), 1); + status = cell(numel(items), 1); + + for i = 1:numel(items) + item = items(i); + file{i} = itemName(item); + A = itemAnalysis(item); + if isempty(A) || ~isfield(A, 'ok') || ~A.ok + windowMode{i} = ''; + detection{i} = 'failed'; + status{i} = analysisMessage(A); + continue; + end + + Ic_A(i) = A.Ic_est_A; + Ia_A(i) = A.Ia_est_A; + Vc_ss_V(i) = A.Vc_ss_V; + Va_ss_V(i) = A.Va_ss_V; + Vc_baseline_V(i) = A.Vc_baseline_V; + Va_baseline_V(i) = A.Va_baseline_V; + dVc_V(i) = A.dVc_V; + dVa_V(i) = A.dVa_V; + Rc_bc_ohm(i) = abs(A.Rc_dV_ohm); + Ra_bc_ohm(i) = abs(A.Ra_dV_ohm); + Ravg_bc_ohm(i) = mean([Rc_bc_ohm(i), Ra_bc_ohm(i)], 'omitnan'); + windowMode{i} = A.windowMode; + detection{i} = A.detectMode; + status{i} = A.message; + end + + T = table(file, Ic_A, Ia_A, Vc_ss_V, Va_ss_V, Vc_baseline_V, Va_baseline_V, ... + dVc_V, dVa_V, Rc_bc_ohm, Ra_bc_ohm, Ravg_bc_ohm, windowMode, detection, status, ... + 'VariableNames', {'File', 'Ic_A', 'Ia_A', 'Vc_ss_V', 'Va_ss_V', ... + 'Vc_baseline_V', 'Va_baseline_V', 'dVc_V', 'dVa_V', 'Rc_bc_ohm', ... + 'Ra_bc_ohm', 'Ravg_bc_ohm', 'WindowMode', 'Detection', 'Status'}); +end + +function [ok, msg] = writeResultsCSV(items, filepath) +%WRITERESULTSCSV Write VT resistance results in legacy CSV format. + + ok = true; + msg = ''; + + fid = fopen(filepath, 'w'); + if fid < 0 + ok = false; + msg = 'Could not open file for writing.'; + if nargout == 0 + error(msg); + end + return; + end + cleaner = onCleanup(@() fclose(fid)); + + try + T = buildResultsTable(items); + fprintf(fid, 'File,Ic_A,Ia_A,Vc_ss_V,Va_ss_V,Vc_baseline_V,Va_baseline_V,dVc_V,dVa_V,Rc_bc_ohm,Ra_bc_ohm,Ravg_bc_ohm,WindowMode,Detection,Status\n'); + for i = 1:height(T) + fprintf(fid, '"%s",%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,%.12g,"%s","%s","%s"\n', ... + csvEscape(T.File{i}), ... + T.Ic_A(i), T.Ia_A(i), T.Vc_ss_V(i), T.Va_ss_V(i), ... + T.Vc_baseline_V(i), T.Va_baseline_V(i), T.dVc_V(i), T.dVa_V(i), ... + T.Rc_bc_ohm(i), T.Ra_bc_ohm(i), T.Ravg_bc_ohm(i), ... + csvEscape(T.WindowMode{i}), ... + csvEscape(T.Detection{i}), ... + csvEscape(T.Status{i})); + end + catch ME + ok = false; + msg = ME.message; + if nargout == 0 + rethrow(ME); + end + end +end + +%% App-local plotting helpers +function [curve, ok, msg] = mainCurve(item) + if isfield(item, 'curve') && ~isempty(item.curve) + curve = item.curve; + ok = true; + msg = sprintf('Using table: %s', curve.name); + elseif isfield(item, 'tables') + [curve, ok, msg] = labkit.dta.getMainCurve(item.tables); + else + curve = struct(); + ok = false; + msg = 'Main transient table not found.'; + end +end + +function q = safeDivide(a, b) + if ~isscalar(a) || ~isscalar(b) || ~isfinite(a) || ~isfinite(b) || abs(b) < eps + q = NaN; + else + q = a / b; + end +end + +function v = chooseFinite(varargin) + v = NaN; + for k = 1:nargin + x = varargin{k}; + if isscalar(x) && isfinite(x) + v = x; + return; + end + end +end + +function [t1, t2] = selectSteadyWindow(p1, p2, modeText) + t1 = p1; + t2 = p2; + if strcmp(modeText, 'Center 60% median') && isfinite(p1) && isfinite(p2) && p2 > p1 + dt = p2 - p1; + t1 = p1 + 0.20 * dt; + t2 = p1 + 0.80 * dt; + end +end + +function [v, window_s] = estimateBaseline(t, y, t1, t2, fallbackValue) + if nargin < 5 + fallbackValue = NaN; + end + + v = medianInWindow(t, y, t1, t2); + if ~isfinite(v) + v = fallbackValue; + end + window_s = max(0, t2 - t1); +end + +function name = itemName(item) + if isfield(item, 'name') + name = item.name; + else + name = ''; + end +end + +function A = itemAnalysis(item) + if isfield(item, 'analysis') + A = item.analysis; + else + A = []; + end +end + +function msg = analysisMessage(A) + msg = ''; + if ~isempty(A) && isfield(A, 'message') + msg = A.message; + end +end + +function out = ternary(cond, a, b) + if cond + out = a; + else + out = b; + end +end + +function shadeWindow(ax, x1, x2, color, alphaVal) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 + return; + end + yl = ylim(ax); + if any(~isfinite(yl)) || yl(1) == yl(2) + return; + end + p = patch(ax, [x1 x2 x2 x1], [yl(1) yl(1) yl(2) yl(2)], color, ... + 'FaceAlpha',alphaVal,'EdgeColor','none','HandleVisibility','off'); + uistack(p,'bottom'); +end + +function addResistanceVTAnnotations(ax, A, cathBaseStartX, cathBaseEndX, anodBaseStartX, anodBaseEndX, ... + cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, cathStartX, cathEndX, anodStartX, anodEndX) + cSteadyMidX = midpointFinite(cSteadyStartX, cSteadyEndX); + aSteadyMidX = midpointFinite(aSteadyStartX, aSteadyEndX); + + drawBaselineSegment(ax, cathBaseStartX, cathBaseEndX, A.Vc_baseline_V, [0.20 0.20 0.20], ... + sprintf('Cath baseline = %.4f V', A.Vc_baseline_V), 'bottom'); + drawBaselineSegment(ax, anodBaseStartX, anodBaseEndX, A.Va_baseline_V, [0.35 0.35 0.35], ... + sprintf('Anod baseline = %.4f V', A.Va_baseline_V), 'top'); + + drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Vc_ss_V, [0.10 0.35 0.80], '--'); + drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Va_ss_V, [0.80 0.35 0.10], '--'); + + plot(ax, cSteadyEndX, A.Vc_ss_V, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... + 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); + plot(ax, aSteadyEndX, A.Va_ss_V, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... + 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); + + text(ax, cSteadyEndX, A.Vc_ss_V, sprintf(' Cath steady V = %.4f V', A.Vc_ss_V), ... + 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); + text(ax, aSteadyEndX, A.Va_ss_V, sprintf(' Anod steady V = %.4f V', A.Va_ss_V), ... + 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); + + if isfinite(cSteadyMidX) && isfinite(A.Vc_baseline_V) && isfinite(A.Vc_ss_V) + plot(ax, [cSteadyMidX cSteadyMidX], [A.Vc_baseline_V A.Vc_ss_V], '--', ... + 'Color',[0.10 0.35 0.80], 'LineWidth',1.0, 'HandleVisibility','off'); + text(ax, cSteadyMidX, 0.5*(A.Vc_baseline_V + A.Vc_ss_V), sprintf(' Cath dV = %.4f V', A.dVc_V), ... + 'Color',[0.10 0.35 0.80], 'VerticalAlignment','middle', 'Interpreter','tex'); + end + if isfinite(aSteadyMidX) && isfinite(A.Va_baseline_V) && isfinite(A.Va_ss_V) + plot(ax, [aSteadyMidX aSteadyMidX], [A.Va_baseline_V A.Va_ss_V], '--', ... + 'Color',[0.80 0.35 0.10], 'LineWidth',1.0, 'HandleVisibility','off'); + text(ax, aSteadyMidX, 0.5*(A.Va_baseline_V + A.Va_ss_V), sprintf(' Anod dV = %.4f V', A.dVa_V), ... + 'Color',[0.80 0.35 0.10], 'VerticalAlignment','middle', 'Interpreter','tex'); + end + + yl = ylim(ax); + dy = yl(2) - yl(1); + yTop = yl(2) - 0.08 * dy; + yLow = yl(2) - 0.16 * dy; + drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); + drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); +end + +function addResistanceITAnnotations(ax, A, cSteadyStartX, cSteadyEndX, aSteadyStartX, aSteadyEndX, ... + cathStartX, cathEndX, anodStartX, anodEndX) + drawLevelSegment(ax, cSteadyStartX, cSteadyEndX, A.Ic_est_A, [0.10 0.35 0.80], '--'); + drawLevelSegment(ax, aSteadyStartX, aSteadyEndX, A.Ia_est_A, [0.80 0.35 0.10], '--'); + + plot(ax, cSteadyEndX, A.Ic_est_A, 'o', 'MarkerFaceColor',[0.10 0.35 0.80], ... + 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); + plot(ax, aSteadyEndX, A.Ia_est_A, 'o', 'MarkerFaceColor',[0.80 0.35 0.10], ... + 'MarkerEdgeColor','k', 'MarkerSize',6, 'HandleVisibility','off'); + + text(ax, cSteadyEndX, A.Ic_est_A, sprintf(' Cath current = %.3f mA', 1e3 * A.Ic_est_A), ... + 'Color',[0.10 0.35 0.80], 'VerticalAlignment','bottom', 'Interpreter','tex'); + text(ax, aSteadyEndX, A.Ia_est_A, sprintf(' Anod current = %.3f mA', 1e3 * A.Ia_est_A), ... + 'Color',[0.80 0.35 0.10], 'VerticalAlignment','top', 'Interpreter','tex'); + + yl = ylim(ax); + dy = yl(2) - yl(1); + yTop = yl(2) - 0.08 * dy; + yLow = yl(2) - 0.16 * dy; + drawDurationBracket(ax, cathStartX, cathEndX, yTop, 'Cathodic pulse'); + drawDurationBracket(ax, anodStartX, anodEndX, yLow, 'Anodic pulse'); +end + +function drawDurationBracket(ax, x1, x2, y, labelText) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) + return; + end + yl = ylim(ax); + h = 0.025 * (yl(2) - yl(1)); + plot(ax, [x1 x2], [y y], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + plot(ax, [x1 x1], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + plot(ax, [x2 x2], [y-h y+h], 'k-', 'LineWidth',1.0, 'HandleVisibility','off'); + text(ax, 0.5 * (x1 + x2), y + 1.4 * h, labelText, 'HorizontalAlignment','center', ... + 'VerticalAlignment','bottom', 'BackgroundColor','w', 'Margin',1, 'HandleVisibility','off'); +end + +function drawBaselineSegment(ax, x1, x2, y, color, labelText, verticalAlignment) + if ~isfinite(y) + return; + end + if isfinite(x1) && isfinite(x2) && x2 > x1 + xStart = x1; + xEnd = x2; + else + xl = xlim(ax); + xStart = xl(1) + 0.04 * (xl(2) - xl(1)); + xEnd = xStart + 0.18 * (xl(2) - xl(1)); + end + plot(ax, [xStart xEnd], [y y], '--', 'Color', color, 'LineWidth',1.4, 'HandleVisibility','off'); + text(ax, xStart, y, [' ' labelText], 'Color', color, 'VerticalAlignment', verticalAlignment, ... + 'BackgroundColor','w', 'Margin',1, 'Interpreter','none', 'HandleVisibility','off'); +end + +function drawLevelSegment(ax, x1, x2, y, color, lineStyle) + if ~isfinite(x1) || ~isfinite(x2) || x2 <= x1 || ~isfinite(y) + return; + end + plot(ax, [x1 x2], [y y], lineStyle, 'Color', color, 'LineWidth',1.3, 'HandleVisibility','off'); +end + +function xm = midpointFinite(x1, x2) + if isfinite(x1) && isfinite(x2) + xm = 0.5 * (x1 + x2); + else + xm = NaN; + end +end + +function txt = formatDurationUs(dt_s) + if ~isscalar(dt_s) || ~isfinite(dt_s) || dt_s < 0 + txt = '-'; + else + txt = sprintf('%.3f us', 1e6 * dt_s); + end +end + +function s = csvEscape(x) + s = strrep(char(x), '"', '""'); +end + +function v = interp1Safe(x, y, xq) + if numel(x) < 2 || any(~isfinite([x(:); y(:)])) + v = NaN; + return; + end + + try + v = interp1(x, y, xq, 'linear', 'extrap'); + catch + idx = nearestIndex(x, xq); + v = y(idx); + end +end + +function idx = nearestIndex(x, xq) + [~, idx] = min(abs(x - xq)); +end + +function m = medianInWindow(t, y, t1, t2) + if ~isfinite(t1) || ~isfinite(t2) || t2 < t1 + m = NaN; + return; + end + + mask = t >= t1 & t <= t2; + if ~any(mask) + m = NaN; + else + m = median(y(mask), 'omitnan'); + end +end diff --git a/apps/electrochem/private/vtResistanceWorkflow.m b/apps/electrochem/private/vtResistanceWorkflow.m new file mode 100644 index 0000000..73db5cd --- /dev/null +++ b/apps/electrochem/private/vtResistanceWorkflow.m @@ -0,0 +1,531 @@ +% App-owned VT resistance workflow helper dispatch. Expected caller: +% labkit_VTResistance_app callbacks and workflow tests. +% Inputs are a command string plus the original helper arguments; outputs match +% the selected helper. Side effects are limited to CSV writes. +function varargout = vtResistanceWorkflow(command, varargin) +%VTRESISTANCEWORKFLOW Dispatch app-owned VT resistance helpers. +% Expected caller: labkit_VTResistance_app callbacks and temporary compatibility +% workflow tests. Inputs are a command string plus the original helper arguments. +% Outputs match the selected helper. Side effects are limited to CSV writes. + + 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{:}); + 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/image_measurement/curvature/curvatureMeasurementWorkflow.m b/apps/image_measurement/curvature/curvatureMeasurementWorkflow.m new file mode 100644 index 0000000..31f9009 --- /dev/null +++ b/apps/image_measurement/curvature/curvatureMeasurementWorkflow.m @@ -0,0 +1,33 @@ +function varargout = curvatureMeasurementWorkflow(command, varargin) +%CURVATUREMEASUREMENTWORKFLOW Dispatch app-owned curvature helpers. +% Expected caller: curvature app tests and migration-time workflow checks. +% Inputs are a workflow command plus command-specific arguments. Outputs match +% the selected app-private helper. This helper has no file side effects. + + switch string(command) + case "computeCurvatureFit" + opts = varargin{3}; + calibration = scaleOptionsFromStruct(opts); + doDensify = optionValue(opts, 'doDensify', true); + denseN = optionValue(opts, 'denseN', 300); + fitPathX = optionValue(opts, 'fitPathX', []); + fitPathY = optionValue(opts, 'fitPathY', []); + varargout{1} = computeCurvatureFit(varargin{1}, varargin{2}, ... + calibration, doDensify, denseN, fitPathX, fitPathY); + case "computeCurveLength" + opts = varargin{3}; + calibration = scaleOptionsFromStruct(opts); + varargout{1} = computeCurveLength(varargin{1}, varargin{2}, calibration); + case "buildCurvatureResultTable" + if numel(varargin) >= 3 + lengthResult = varargin{3}; + else + lengthResult = lengthResultFromFit(varargin{1}); + end + varargout{1} = buildCurvatureResultTable(varargin{1}, ... + string(varargin{2}), lengthResult); + otherwise + error('labkit:CurvatureMeasurement:UnknownWorkflowCommand', ... + 'Unknown curvature workflow helper command: %s.', command); + end +end diff --git a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m index 1226f7e..5c2d243 100644 --- a/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m +++ b/apps/image_measurement/curvature/labkit_CurvatureMeasurement_app.m @@ -2,7 +2,7 @@ %LABKIT_CURVATUREMEASUREMENT_APP Measure curve radius and curvature from images. [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_CurvatureMeasurement_app', varargin, nargout, curvatureAppTestHandlers()); + 'labkit_CurvatureMeasurement_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -27,25 +27,11 @@ S.fit = emptyFitResult(); S.length = emptyLengthResult(); - workbenchOpts = struct( ... - 'rightTitle', 'Measurement Preview', ... - 'rightGridSize', [1 1], ... - 'rightRowHeight', {{'1x'}}); - workbenchOpts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [5 1], ... - {140, 105, 355, 225, 160}, ... - struct('resizeRows', [1 2 3 4], ... - 'resizeOptions', struct('minTopHeight', 140, 'minBottomHeight', 90))), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {170, '1x'}, ... - struct('resizeRows', 1)), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - ui = labkit.ui.app.createShell(struct( ... 'title', 'Image Curvature Measurement', ... 'position', [90 70 1420 860], ... 'leftWidth', 390, ... - 'options', workbenchOpts)); + 'options', curvatureShellOptions())); fig = ui.fig; layFA = ui.filesAnalysisGrid; laySR = ui.summaryResultsGrid; @@ -57,114 +43,39 @@ 'defaultScrollFcn', @onPreviewScroll, ... 'onTrace', debugLog.trace)); - imagePanel = labkit.ui.view.section(layFA, 'Image', 1, [3 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - imageGrid = imagePanel.grid; - - btnOpenImage = uibutton(imageGrid, 'Text', 'Open image', ... - 'ButtonPushedFcn', @onOpenImage); - btnOpenImage.Layout.Row = 1; - btnOpenImage.Layout.Column = [1 2]; - - txtImage = labkit.ui.view.form(imageGrid, 'readonly', ... - 'Value', 'No image loaded'); - txtImage.Layout.Row = 2; - txtImage.Layout.Column = [1 2]; - - txtPointCount = labkit.ui.view.form(imageGrid, 'readonly', ... - 'Value', 'Points: 0'); - txtPointCount.Layout.Row = 3; - txtPointCount.Layout.Column = [1 2]; - - editPanel = labkit.ui.view.section(layFA, 'Curve Editing', 2, [2 2], ... - struct('rowHeight', {{'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - editGrid = editPanel.grid; - - btnStartCurve = uibutton(editGrid, 'Text', 'Start curve edit', ... - 'ButtonPushedFcn', @onStartCurveEdit); - btnStartCurve.Layout.Row = 1; - btnStartCurve.Layout.Column = [1 2]; - - btnUndoPoint = uibutton(editGrid, 'Text', 'Undo last point', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onUndoCurvePoint); - btnUndoPoint.Layout.Row = 2; - btnUndoPoint.Layout.Column = 1; - btnClearCurve = uibutton(editGrid, 'Text', 'Clear curve', ... - 'Enable', 'off', ... - 'ButtonPushedFcn', @onClearCurve); - btnClearCurve.Layout.Row = 2; - btnClearCurve.Layout.Column = 2; - - scaleTool = labkit.ui.tool.scaleBar(layFA, 3, imageRuntime, ... - struct('onBeforeReferenceEdit', @onBeforeReferenceEdit, ... + controls = createCurvatureControls(layFA, laySR, layLog, imageRuntime, struct( ... + 'onOpenImage', @onOpenImage, ... + 'onStartCurveEdit', @onStartCurveEdit, ... + 'onUndoCurvePoint', @onUndoCurvePoint, ... + 'onClearCurve', @onClearCurve, ... + 'onBeforeReferenceEdit', @onBeforeReferenceEdit, ... 'onReferenceEditChanged', @onReferenceEditChanged, ... - 'onCalibrationChanged', @onCalibrationSettingsChanged, ... - 'onScaleBarChanged', @onScaleBarSettingsChanged, ... + 'onCalibrationSettingsChanged', @onCalibrationSettingsChanged, ... + 'onScaleBarSettingsChanged', @onScaleBarSettingsChanged, ... 'onScaleBarPlaced', @onScaleBarPlaced, ... - 'onError', @onScaleToolError, ... + 'onScaleToolError', @onScaleToolError, ... + 'onShowDenseChanged', @(~,~) refreshImageOverlay(), ... + 'onFitCurvature', @onFitCurvature, ... + 'onMeasureCurveLength', @onMeasureCurveLength, ... + 'onExportCSV', @onExportCSV, ... + 'onExportOverlay', @onExportOverlay, ... 'onTrace', debugLog.trace)); - - fitPanel = labkit.ui.view.section(layFA, 'Fit + Export', 4, [7 2], ... - struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... - 'columnWidth', {{145, '1x'}})); - fitGrid = fitPanel.grid; - - chkDensify = uicheckbox(fitGrid, 'Text', 'Densify before circle fit', 'Value', true); - chkDensify.Layout.Row = 1; - chkDensify.Layout.Column = [1 2]; - - [lblDenseN, edtDenseN] = labkit.ui.view.form(fitGrid, 'spinner', ... - 'Dense point count:', 'Value', 300, 'Limits', [3 Inf], 'Step', 25); - lblDenseN.Layout.Row = 2; - lblDenseN.Layout.Column = 1; - edtDenseN.Layout.Row = 2; - edtDenseN.Layout.Column = 2; - - chkShowDense = uicheckbox(fitGrid, 'Text', 'Show dense fit points', ... - 'Value', true, ... - 'ValueChangedFcn', @(~,~) refreshImageOverlay()); - chkShowDense.Layout.Row = 3; - chkShowDense.Layout.Column = [1 2]; - - btnFit = uibutton(fitGrid, 'Text', 'Fit circle + curvature', ... - 'ButtonPushedFcn', @onFitCurvature); - btnFit.Layout.Row = 4; - btnFit.Layout.Column = [1 2]; - - btnMeasureLength = uibutton(fitGrid, 'Text', 'Measure curve length', ... - 'ButtonPushedFcn', @onMeasureCurveLength); - btnMeasureLength.Layout.Row = 5; - btnMeasureLength.Layout.Column = [1 2]; - - btnExportCSV = uibutton(fitGrid, 'Text', 'Export result CSV', ... - 'ButtonPushedFcn', @onExportCSV); - btnExportCSV.Layout.Row = 6; - btnExportCSV.Layout.Column = [1 2]; - btnExportOverlay = uibutton(fitGrid, 'Text', 'Export overlay PNG', ... - 'ButtonPushedFcn', @onExportOverlay); - btnExportOverlay.Layout.Row = 7; - btnExportOverlay.Layout.Column = [1 2]; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 5, { ... - '1. Open an image and start curve editing.', ... - '2. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.', ... - '3. Calibrate with measured or typed reference pixels, a real reference length, and a unit.', ... - '4. Place the final scale bar, then fit curvature or measure curve length.'}); - - resultTable = uitable(laySR, ... - 'ColumnName', {'Metric', 'Value'}, ... - 'Data', initialResultTable()); - resultTable.Layout.Row = 1; - - txtDetails = uitextarea(laySR, 'Editable', 'off'); - labkit.ui.view.place(txtDetails, laySR, 2); - txtDetails.Value = {'No curvature result yet.'}; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; + txtImage = controls.txtImage; + txtPointCount = controls.txtPointCount; + btnStartCurve = controls.btnStartCurve; + btnUndoPoint = controls.btnUndoPoint; + btnClearCurve = controls.btnClearCurve; + scaleTool = controls.scaleTool; + chkDensify = controls.chkDensify; + edtDenseN = controls.edtDenseN; + chkShowDense = controls.chkShowDense; + btnFit = controls.btnFit; + btnMeasureLength = controls.btnMeasureLength; + btnExportCSV = controls.btnExportCSV; + btnExportOverlay = controls.btnExportOverlay; + resultTable = controls.resultTable; + txtDetails = controls.txtDetails; + txtLog = controls.txtLog; if debugLog.enabled debugLog.attachTextLog(txtLog); @@ -540,35 +451,11 @@ function refreshImageOverlay() function plotStaticCurveAnchors(ax) points = [S.xPix(:), S.yPix(:)]; - if isempty(points) - return; - end - curve = points; if ~isempty(S.curveEditor) curve = S.curveEditor.curvePoints(); end - if ~isempty(curve) - plot(ax, curve(:, 1), curve(:, 2), '-', ... - 'Color', [0 0.45 0.95], ... - 'LineWidth', 1.5, ... - 'HitTest', 'off', ... - 'DisplayName', 'curve'); - end - if S.fit.ok - if chkShowDense.Value - plotDenseFitPoints(ax, S.fit); - end - plotAnchorResiduals(ax, points, S.fit); - end - plot(ax, points(:, 1), points(:, 2), 'o', ... - 'LineStyle', 'none', ... - 'Color', [1 0.85 0], ... - 'MarkerFaceColor', [0 0.45 0.95], ... - 'LineWidth', 1.2, ... - 'MarkerSize', 7, ... - 'HitTest', 'off', ... - 'DisplayName', 'anchors'); + plotStaticCurveAnchorsView(ax, points, curve, S.fit, chkShowDense.Value); end function onPreviewScroll(~, event) @@ -585,41 +472,11 @@ function onPreviewScroll(~, event) end function refreshSummary() - txtPointCount.Value = sprintf('Points: %d', numel(S.xPix)); - if S.fit.ok - resultTable.Data = fitResultTableData(S.fit, S.length); - txtDetails.Value = { ... - sprintf('Image: %s', emptyDash(S.imagePath)), ... - sprintf('Center: xc = %.6f px, yc = %.6f px', S.fit.xc_px, S.fit.yc_px), ... - sprintf('Radius: %.6f %s', S.fit.R_show, S.fit.unitLen), ... - sprintf('Curvature: %.6f %s', S.fit.kappa_show, S.fit.unitK), ... - sprintf('Curve length: %.6f %s', S.length.length_show, S.length.unitLen), ... - sprintf('RMSE: %.6f %s', S.fit.rmse_show, S.fit.unitLen), ... - sprintf('reference = %.6g px / %.6g %s; px/%s = %.6g', ... - S.fit.referencePx, S.fit.referenceLength, S.fit.scaleUnit, ... - S.fit.scaleUnit, S.fit.px_per_unit)}; - elseif S.length.ok - resultTable.Data = lengthResultTableData(S.length); - txtDetails.Value = { ... - sprintf('Image: %s', emptyDash(S.imagePath)), ... - sprintf('Curve length: %.6f %s', S.length.length_show, S.length.unitLen), ... - sprintf('Curve length: %.6f px', S.length.length_px), ... - sprintf('Points used: %d; px/%s = %.6g', ... - S.length.pointCount, S.length.scaleUnit, S.length.px_per_unit)}; - else - resultTable.Data = initialResultTable(); - if S.curveEditActive - txtDetails.Value = {'Curve edit active. Double-click blank image space to add/insert points, drag points to move them, double-click a point to delete it. Use the scroll wheel over the image to zoom.'}; - elseif scaleTool.isReferenceEditActive() - txtDetails.Value = {'Reference-pixel edit active. Double-click two endpoints or drag existing endpoints; this sets the calibration pixel length only.'}; - elseif numel(S.xPix) >= 3 - txtDetails.Value = {'Curve points are ready. Fit curvature or measure curve length.'}; - elseif numel(S.xPix) >= 2 - txtDetails.Value = {'Curve points are ready. Measure curve length, or add more points before fitting curvature.'}; - else - txtDetails.Value = {'Load an image and start curve editing.'}; - end - end + summary = curvatureSummaryViewData(S.imagePath, S.xPix, S.fit, ... + S.length, S.curveEditActive, scaleTool.isReferenceEditActive()); + txtPointCount.Value = summary.pointCountText; + resultTable.Data = summary.tableData; + txtDetails.Value = summary.details; updateModeControls(); end @@ -638,187 +495,3 @@ function showError(titleText, message) uialert(fig, message, titleText); end end - -function handlers = curvatureAppTestHandlers() - handlers = struct( ... - 'command', {'computeCurvatureFit', 'computeCurveLength', 'buildCurvatureResultTable'}, ... - 'minArgs', {3, 3, 2}, ... - 'maxArgs', {3, 3, 3}, ... - 'maxOutputs', {1, 1, 1}, ... - 'run', {@runComputeCurvatureFit, @runComputeCurveLength, @runBuildCurvatureResultTable}); -end - -function outputs = runComputeCurvatureFit(args) - opts = args{3}; - calibration = scaleOptionsFromStruct(opts); - doDensify = optionValue(opts, 'doDensify', true); - denseN = optionValue(opts, 'denseN', 300); - fitPathX = optionValue(opts, 'fitPathX', []); - fitPathY = optionValue(opts, 'fitPathY', []); - outputs = {computeCurvatureFit(args{1}, args{2}, calibration, ... - doDensify, denseN, fitPathX, fitPathY)}; -end - -function outputs = runBuildCurvatureResultTable(args) - if numel(args) >= 3 - lengthResult = args{3}; - else - lengthResult = lengthResultFromFit(args{1}); - end - outputs = {buildCurvatureResultTable(args{1}, string(args{2}), lengthResult)}; -end - -function outputs = runComputeCurveLength(args) - opts = args{3}; - calibration = scaleOptionsFromStruct(opts); - outputs = {computeCurveLength(args{1}, args{2}, calibration)}; -end - -function plotDenseFitPoints(ax, fit) - if numel(fit.xFit) <= numel(fit.xPix) - return; - end - plot(ax, fit.xFit, fit.yFit, '.', ... - 'Color', [0.95 0.2 0.95], ... - 'MarkerSize', 7, ... - 'HitTest', 'off', ... - 'DisplayName', 'dense fit points'); -end - -function plotAnchorResiduals(ax, points, fit) - dx = points(:, 1) - fit.xc_px; - dy = points(:, 2) - fit.yc_px; - radii = hypot(dx, dy); - valid = isfinite(radii) & radii > eps; - if ~any(valid) - return; - end - - circleX = fit.xc_px + fit.R_px .* dx(valid) ./ radii(valid); - circleY = fit.yc_px + fit.R_px .* dy(valid) ./ radii(valid); - anchorX = points(valid, 1); - anchorY = points(valid, 2); - xSegments = [anchorX.'; circleX.'; NaN(1, numel(circleX))]; - ySegments = [anchorY.'; circleY.'; NaN(1, numel(circleY))]; - plot(ax, xSegments(:), ySegments(:), '--', ... - 'Color', [1 0.9 0], ... - 'LineWidth', 1.2, ... - 'HitTest', 'off', ... - 'DisplayName', 'anchor residuals'); -end - -function data = initialResultTable() - data = { ... - 'Curve length', '-'; ... - 'Radius', '-'; ... - 'Curvature', '-'; ... - 'RMSE', '-'; ... - 'Center X', '-'; ... - 'Center Y', '-'; ... - 'Pixels/unit', '-'}; -end - -function tf = insideImageBounds(x, y, imageSize) - tf = isfinite(x) && isfinite(y) && ... - x >= 0.5 && y >= 0.5 && ... - x <= imageSize(2) + 0.5 && y <= imageSize(1) + 0.5; -end - -function zoomAxesAtPoint(ax, x, y, scrollCount, imageSize) - if scrollCount == 0 - return; - end - - fullX = [0.5, imageSize(2) + 0.5]; - fullY = [0.5, imageSize(1) + 0.5]; - zoomFactor = 1.20 ^ scrollCount; - - currentX = ax.XLim; - currentY = ax.YLim; - newWidth = diff(currentX) * zoomFactor; - newHeight = diff(currentY) * zoomFactor; - - minSpan = 10; - newWidth = min(max(newWidth, minSpan), diff(fullX)); - newHeight = min(max(newHeight, minSpan), diff(fullY)); - - xFrac = (x - currentX(1)) / max(eps, diff(currentX)); - yFrac = (y - currentY(1)) / max(eps, diff(currentY)); - xFrac = min(max(xFrac, 0), 1); - yFrac = min(max(yFrac, 0), 1); - - newX = [x - xFrac * newWidth, x + (1 - xFrac) * newWidth]; - newY = [y - yFrac * newHeight, y + (1 - yFrac) * newHeight]; - - ax.XLim = clampLimits(newX, fullX); - ax.YLim = clampLimits(newY, fullY); -end - -function limits = clampLimits(limits, fullLimits) - span = diff(limits); - fullSpan = diff(fullLimits); - if span >= fullSpan - limits = fullLimits; - return; - end - if limits(1) < fullLimits(1) - limits = [fullLimits(1), fullLimits(1) + span]; - end - if limits(2) > fullLimits(2) - limits = [fullLimits(2) - span, fullLimits(2)]; - end -end - -function data = fitResultTableData(fit, lengthResult) - if nargin < 2 || isempty(lengthResult) - lengthResult = lengthResultFromFit(fit); - end - data = { ... - 'Curve length', sprintf('%.6g %s', lengthResult.length_show, lengthResult.unitLen); ... - 'Radius', sprintf('%.6g %s', fit.R_show, fit.unitLen); ... - 'Curvature', sprintf('%.6g %s', fit.kappa_show, fit.unitK); ... - 'RMSE', sprintf('%.6g %s', fit.rmse_show, fit.unitLen); ... - 'Center X', sprintf('%.6f px', fit.xc_px); ... - 'Center Y', sprintf('%.6f px', fit.yc_px); ... - sprintf('Pixels/%s', fit.scaleUnit), sprintf('%.6g', fit.px_per_unit)}; -end - -function data = lengthResultTableData(lengthResult) - data = { ... - 'Curve length', sprintf('%.6g %s', lengthResult.length_show, lengthResult.unitLen); ... - 'Curve length px', sprintf('%.6g px', lengthResult.length_px); ... - 'Length points', sprintf('%d', lengthResult.pointCount); ... - sprintf('Pixels/%s', lengthResult.scaleUnit), sprintf('%.6g', lengthResult.px_per_unit); ... - 'Radius', '-'; ... - 'Curvature', '-'; ... - 'RMSE', '-'}; -end - -function s = emptyDash(value) - if strlength(string(value)) == 0 - s = '-'; - else - s = char(value); - end -end - -function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end -end - -function tf = isReferenceEditReason(reason) - tf = false; - if ischar(reason) - text = string(reason); - elseif isstring(reason) && isscalar(reason) - text = reason; - else - return; - end - tf = any(text == ["set points", "add point", "delete point", ... - "move point", "clear points", "start", "finish"]); -end diff --git a/apps/image_measurement/curvature/private/buildCurvatureResultTable.m b/apps/image_measurement/curvature/private/buildCurvatureResultTable.m index 04c4c00..fd15357 100644 --- a/apps/image_measurement/curvature/private/buildCurvatureResultTable.m +++ b/apps/image_measurement/curvature/private/buildCurvatureResultTable.m @@ -1,9 +1,12 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function T = buildCurvatureResultTable(fit, imagePath, lengthResult) %BUILDCURVATURERESULTTABLE Build export table for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app export callback and __labkit_test__ -% result-table handler. +% labkit_CurvatureMeasurement_app export callback and temporary compatibility +% result-table test handler. % % Inputs/outputs: % Fit struct, image path, and optional length-result struct. Returns the diff --git a/apps/image_measurement/curvature/private/clampLimits.m b/apps/image_measurement/curvature/private/clampLimits.m new file mode 100644 index 0000000..7c2294a --- /dev/null +++ b/apps/image_measurement/curvature/private/clampLimits.m @@ -0,0 +1,19 @@ +% App-owned curvature axes limit helper. Expected caller: zoomAxesAtPoint. +% Inputs are requested limits and full limits. Output is clamped limits with +% width preserved when possible. This helper has no side effects. +function limits = clampLimits(limits, fullLimits) +%CLAMPLIMITS Clamp axes limits to full image limits. + + span = diff(limits); + fullSpan = diff(fullLimits); + if span >= fullSpan + limits = fullLimits; + return; + end + if limits(1) < fullLimits(1) + limits = [fullLimits(1), fullLimits(1) + span]; + end + if limits(2) > fullLimits(2) + limits = [fullLimits(2) - span, fullLimits(2)]; + end +end diff --git a/apps/image_measurement/curvature/private/computeCurvatureFit.m b/apps/image_measurement/curvature/private/computeCurvatureFit.m index 0fee916..9394622 100644 --- a/apps/image_measurement/curvature/private/computeCurvatureFit.m +++ b/apps/image_measurement/curvature/private/computeCurvatureFit.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function fit = computeCurvatureFit(xPix, yPix, calibration, doDensify, denseN, fitPathX, fitPathY) %COMPUTECURVATUREFIT Fit image-curve curvature for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app callbacks and __labkit_test__ handlers. +% labkit_CurvatureMeasurement_app callbacks and workflow tests. % % Inputs/outputs: % Pixel anchor vectors, a labkit.ui scale-bar calibration struct, and diff --git a/apps/image_measurement/curvature/private/computeCurveLength.m b/apps/image_measurement/curvature/private/computeCurveLength.m index fc7092f..54ec4e4 100644 --- a/apps/image_measurement/curvature/private/computeCurveLength.m +++ b/apps/image_measurement/curvature/private/computeCurveLength.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function lengthResult = computeCurveLength(xPix, yPix, calibration) %COMPUTECURVELENGTH Measure traced curve length for labkit_CurvatureMeasurement_app. % % Expected caller: -% labkit_CurvatureMeasurement_app callbacks, test handlers, and private +% labkit_CurvatureMeasurement_app callbacks, workflow tests, and private % curvature fit helpers. % % Inputs/outputs: diff --git a/apps/image_measurement/curvature/private/createCurvatureControls.m b/apps/image_measurement/curvature/private/createCurvatureControls.m new file mode 100644 index 0000000..acc11c6 --- /dev/null +++ b/apps/image_measurement/curvature/private/createCurvatureControls.m @@ -0,0 +1,119 @@ +% App-owned curvature control construction helper. Expected caller: +% labkit_CurvatureMeasurement_app. Inputs are shell grids, an image tool runtime, +% and callbacks. Output is a struct of app UI handles. Side effects are limited +% to creating UI components under the supplied parents. +function controls = createCurvatureControls(layFA, laySR, layLog, imageRuntime, callbacks) +%CREATECURVATURECONTROLS Create the curvature app control panels. + + imagePanel = labkit.ui.view.section(layFA, 'Image', 1, [3 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{145, '1x'}})); + imageGrid = imagePanel.grid; + + btnOpenImage = uibutton(imageGrid, 'Text', 'Open image', ... + 'ButtonPushedFcn', callbacks.onOpenImage); + btnOpenImage.Layout.Row = 1; + btnOpenImage.Layout.Column = [1 2]; + + controls.txtImage = labkit.ui.view.form(imageGrid, 'readonly', ... + 'Value', 'No image loaded'); + controls.txtImage.Layout.Row = 2; + controls.txtImage.Layout.Column = [1 2]; + + controls.txtPointCount = labkit.ui.view.form(imageGrid, 'readonly', ... + 'Value', 'Points: 0'); + controls.txtPointCount.Layout.Row = 3; + controls.txtPointCount.Layout.Column = [1 2]; + + editPanel = labkit.ui.view.section(layFA, 'Curve Editing', 2, [2 2], ... + struct('rowHeight', {{'fit', 'fit'}}, ... + 'columnWidth', {{145, '1x'}})); + editGrid = editPanel.grid; + + controls.btnStartCurve = uibutton(editGrid, 'Text', 'Start curve edit', ... + 'ButtonPushedFcn', callbacks.onStartCurveEdit); + controls.btnStartCurve.Layout.Row = 1; + controls.btnStartCurve.Layout.Column = [1 2]; + + controls.btnUndoPoint = uibutton(editGrid, 'Text', 'Undo last point', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onUndoCurvePoint); + controls.btnUndoPoint.Layout.Row = 2; + controls.btnUndoPoint.Layout.Column = 1; + controls.btnClearCurve = uibutton(editGrid, 'Text', 'Clear curve', ... + 'Enable', 'off', ... + 'ButtonPushedFcn', callbacks.onClearCurve); + controls.btnClearCurve.Layout.Row = 2; + controls.btnClearCurve.Layout.Column = 2; + + controls.scaleTool = labkit.ui.tool.scaleBar(layFA, 3, imageRuntime, ... + struct('onBeforeReferenceEdit', callbacks.onBeforeReferenceEdit, ... + 'onReferenceEditChanged', callbacks.onReferenceEditChanged, ... + 'onCalibrationChanged', callbacks.onCalibrationSettingsChanged, ... + 'onScaleBarChanged', callbacks.onScaleBarSettingsChanged, ... + 'onScaleBarPlaced', callbacks.onScaleBarPlaced, ... + 'onError', callbacks.onScaleToolError, ... + 'onTrace', callbacks.onTrace)); + + fitPanel = labkit.ui.view.section(layFA, 'Fit + Export', 4, [7 2], ... + struct('rowHeight', {{'fit', 'fit', 'fit', 'fit', 'fit', 'fit', 'fit'}}, ... + 'columnWidth', {{145, '1x'}})); + fitGrid = fitPanel.grid; + + controls.chkDensify = uicheckbox(fitGrid, ... + 'Text', 'Densify before circle fit', 'Value', true); + controls.chkDensify.Layout.Row = 1; + controls.chkDensify.Layout.Column = [1 2]; + + [lblDenseN, controls.edtDenseN] = labkit.ui.view.form(fitGrid, 'spinner', ... + 'Dense point count:', 'Value', 300, 'Limits', [3 Inf], 'Step', 25); + lblDenseN.Layout.Row = 2; + lblDenseN.Layout.Column = 1; + controls.edtDenseN.Layout.Row = 2; + controls.edtDenseN.Layout.Column = 2; + + controls.chkShowDense = uicheckbox(fitGrid, ... + 'Text', 'Show dense fit points', ... + 'Value', true, ... + 'ValueChangedFcn', callbacks.onShowDenseChanged); + controls.chkShowDense.Layout.Row = 3; + controls.chkShowDense.Layout.Column = [1 2]; + + controls.btnFit = uibutton(fitGrid, 'Text', 'Fit circle + curvature', ... + 'ButtonPushedFcn', callbacks.onFitCurvature); + controls.btnFit.Layout.Row = 4; + controls.btnFit.Layout.Column = [1 2]; + + controls.btnMeasureLength = uibutton(fitGrid, ... + 'Text', 'Measure curve length', ... + 'ButtonPushedFcn', callbacks.onMeasureCurveLength); + controls.btnMeasureLength.Layout.Row = 5; + controls.btnMeasureLength.Layout.Column = [1 2]; + + controls.btnExportCSV = uibutton(fitGrid, 'Text', 'Export result CSV', ... + 'ButtonPushedFcn', callbacks.onExportCSV); + controls.btnExportCSV.Layout.Row = 6; + controls.btnExportCSV.Layout.Column = [1 2]; + controls.btnExportOverlay = uibutton(fitGrid, 'Text', 'Export overlay PNG', ... + 'ButtonPushedFcn', callbacks.onExportOverlay); + controls.btnExportOverlay.Layout.Row = 7; + controls.btnExportOverlay.Layout.Column = [1 2]; + + labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 5, { ... + '1. Open an image and start curve editing.', ... + '2. Double-click blank image space to add/insert points; drag points to move; double-click a point to delete it.', ... + '3. Calibrate with measured or typed reference pixels, a real reference length, and a unit.', ... + '4. Place the final scale bar, then fit curvature or measure curve length.'}); + + controls.resultTable = uitable(laySR, ... + 'ColumnName', {'Metric', 'Value'}, ... + 'Data', initialResultTable()); + controls.resultTable.Layout.Row = 1; + + controls.txtDetails = uitextarea(laySR, 'Editable', 'off'); + labkit.ui.view.place(controls.txtDetails, laySR, 2); + controls.txtDetails.Value = {'No curvature result yet.'}; + + logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); + controls.txtLog = logUi.textArea; +end diff --git a/apps/image_measurement/curvature/private/curvatureShellOptions.m b/apps/image_measurement/curvature/private/curvatureShellOptions.m new file mode 100644 index 0000000..cf7fd31 --- /dev/null +++ b/apps/image_measurement/curvature/private/curvatureShellOptions.m @@ -0,0 +1,20 @@ +% App-owned curvature shell options helper. Expected caller: +% labkit_CurvatureMeasurement_app. Output is the createShell options struct. +% Encodes only layout constants and has no side effects. +function opts = curvatureShellOptions() +%CURVATURESHELLOPTIONS Return shell options for the curvature app. + + opts = struct( ... + 'rightTitle', 'Measurement Preview', ... + 'rightGridSize', [1 1], ... + 'rightRowHeight', {{'1x'}}); + opts.tabs = [ ... + labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [5 1], ... + {140, 105, 355, 225, 160}, ... + struct('resizeRows', [1 2 3 4], ... + 'resizeOptions', struct('minTopHeight', 140, 'minBottomHeight', 90))), ... + labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... + {170, '1x'}, ... + struct('resizeRows', 1)), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; +end diff --git a/apps/image_measurement/curvature/private/curvatureSummaryViewData.m b/apps/image_measurement/curvature/private/curvatureSummaryViewData.m new file mode 100644 index 0000000..cbef5d1 --- /dev/null +++ b/apps/image_measurement/curvature/private/curvatureSummaryViewData.m @@ -0,0 +1,44 @@ +% App-owned curvature summary view-data helper. Expected caller: +% labkit_CurvatureMeasurement_app refreshSummary. Inputs are app state values +% and edit-mode flags. Output contains point text, result table data, and detail +% lines. This helper has no UI side effects. +function summary = curvatureSummaryViewData(imagePath, xPix, fit, lengthResult, curveEditActive, referenceEditActive) +%CURVATURESUMMARYVIEWDATA Build visible summary/table data for app state. + + summary = struct(); + summary.pointCountText = sprintf('Points: %d', numel(xPix)); + if fit.ok + summary.tableData = fitResultTableData(fit, lengthResult); + summary.details = { ... + sprintf('Image: %s', emptyDash(imagePath)), ... + sprintf('Center: xc = %.6f px, yc = %.6f px', fit.xc_px, fit.yc_px), ... + sprintf('Radius: %.6f %s', fit.R_show, fit.unitLen), ... + sprintf('Curvature: %.6f %s', fit.kappa_show, fit.unitK), ... + sprintf('Curve length: %.6f %s', lengthResult.length_show, lengthResult.unitLen), ... + sprintf('RMSE: %.6f %s', fit.rmse_show, fit.unitLen), ... + sprintf('reference = %.6g px / %.6g %s; px/%s = %.6g', ... + fit.referencePx, fit.referenceLength, fit.scaleUnit, ... + fit.scaleUnit, fit.px_per_unit)}; + elseif lengthResult.ok + summary.tableData = lengthResultTableData(lengthResult); + summary.details = { ... + sprintf('Image: %s', emptyDash(imagePath)), ... + sprintf('Curve length: %.6f %s', lengthResult.length_show, lengthResult.unitLen), ... + sprintf('Curve length: %.6f px', lengthResult.length_px), ... + sprintf('Points used: %d; px/%s = %.6g', ... + lengthResult.pointCount, lengthResult.scaleUnit, lengthResult.px_per_unit)}; + else + summary.tableData = initialResultTable(); + if curveEditActive + summary.details = {'Curve edit active. Double-click blank image space to add/insert points, drag points to move them, double-click a point to delete it. Use the scroll wheel over the image to zoom.'}; + elseif referenceEditActive + summary.details = {'Reference-pixel edit active. Double-click two endpoints or drag existing endpoints; this sets the calibration pixel length only.'}; + elseif numel(xPix) >= 3 + summary.details = {'Curve points are ready. Fit curvature or measure curve length.'}; + elseif numel(xPix) >= 2 + summary.details = {'Curve points are ready. Measure curve length, or add more points before fitting curvature.'}; + else + summary.details = {'Load an image and start curve editing.'}; + end + end +end diff --git a/apps/image_measurement/curvature/private/emptyDash.m b/apps/image_measurement/curvature/private/emptyDash.m new file mode 100644 index 0000000..0d9c58c --- /dev/null +++ b/apps/image_measurement/curvature/private/emptyDash.m @@ -0,0 +1,12 @@ +% App-owned curvature display helper. Expected caller: +% labkit_CurvatureMeasurement_app summary rendering. Input is a value to render. +% Output is '-' for empty values or char(value) otherwise. +function s = emptyDash(value) +%EMPTYDASH Render an empty app value as a dash. + + if strlength(string(value)) == 0 + s = '-'; + else + s = char(value); + end +end diff --git a/apps/image_measurement/curvature/private/emptyFitResult.m b/apps/image_measurement/curvature/private/emptyFitResult.m index 3d563bd..82592cb 100644 --- a/apps/image_measurement/curvature/private/emptyFitResult.m +++ b/apps/image_measurement/curvature/private/emptyFitResult.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function fit = emptyFitResult() %EMPTYFITRESULT Return default fit result for labkit_CurvatureMeasurement_app. % diff --git a/apps/image_measurement/curvature/private/emptyLengthResult.m b/apps/image_measurement/curvature/private/emptyLengthResult.m index 716cbe0..629f90c 100644 --- a/apps/image_measurement/curvature/private/emptyLengthResult.m +++ b/apps/image_measurement/curvature/private/emptyLengthResult.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function lengthResult = emptyLengthResult() %EMPTYLENGTHRESULT Return default length result for labkit_CurvatureMeasurement_app. % diff --git a/apps/image_measurement/curvature/private/fitResultTableData.m b/apps/image_measurement/curvature/private/fitResultTableData.m new file mode 100644 index 0000000..38ffc30 --- /dev/null +++ b/apps/image_measurement/curvature/private/fitResultTableData.m @@ -0,0 +1,18 @@ +% App-owned curvature result table formatter. Expected caller: +% labkit_CurvatureMeasurement_app. Inputs are fit and length result structs. +% Output is metric/value cell data for the visible result table. +function data = fitResultTableData(fit, lengthResult) +%FITRESULTTABLEDATA Return visible result table rows for a fit result. + + if nargin < 2 || isempty(lengthResult) + lengthResult = lengthResultFromFit(fit); + end + data = { ... + 'Curve length', sprintf('%.6g %s', lengthResult.length_show, lengthResult.unitLen); ... + 'Radius', sprintf('%.6g %s', fit.R_show, fit.unitLen); ... + 'Curvature', sprintf('%.6g %s', fit.kappa_show, fit.unitK); ... + 'RMSE', sprintf('%.6g %s', fit.rmse_show, fit.unitLen); ... + 'Center X', sprintf('%.6f px', fit.xc_px); ... + 'Center Y', sprintf('%.6f px', fit.yc_px); ... + sprintf('Pixels/%s', fit.scaleUnit), sprintf('%.6g', fit.px_per_unit)}; +end diff --git a/apps/image_measurement/curvature/private/initialResultTable.m b/apps/image_measurement/curvature/private/initialResultTable.m new file mode 100644 index 0000000..d45eafe --- /dev/null +++ b/apps/image_measurement/curvature/private/initialResultTable.m @@ -0,0 +1,15 @@ +% App-owned curvature result table initializer. Expected caller: +% labkit_CurvatureMeasurement_app. Output is the default metric/value cell +% table data. This helper has no side effects. +function data = initialResultTable() +%INITIALRESULTTABLE Return default curvature result table rows. + + data = { ... + 'Curve length', '-'; ... + 'Radius', '-'; ... + 'Curvature', '-'; ... + 'RMSE', '-'; ... + 'Center X', '-'; ... + 'Center Y', '-'; ... + 'Pixels/unit', '-'}; +end diff --git a/apps/image_measurement/curvature/private/insideImageBounds.m b/apps/image_measurement/curvature/private/insideImageBounds.m new file mode 100644 index 0000000..0cbdb87 --- /dev/null +++ b/apps/image_measurement/curvature/private/insideImageBounds.m @@ -0,0 +1,10 @@ +% App-owned curvature axes hit-test helper. Expected caller: +% labkit_CurvatureMeasurement_app scroll handling. Inputs are x/y points and an +% image size. Output is a scalar logical. This helper has no side effects. +function tf = insideImageBounds(x, y, imageSize) +%INSIDEIMAGEBOUNDS Return true when a point is inside image bounds. + + tf = isfinite(x) && isfinite(y) && ... + x >= 0.5 && y >= 0.5 && ... + x <= imageSize(2) + 0.5 && y <= imageSize(1) + 0.5; +end diff --git a/apps/image_measurement/curvature/private/isReferenceEditReason.m b/apps/image_measurement/curvature/private/isReferenceEditReason.m new file mode 100644 index 0000000..a970016 --- /dev/null +++ b/apps/image_measurement/curvature/private/isReferenceEditReason.m @@ -0,0 +1,17 @@ +% App-owned curvature scale-tool reason helper. Expected caller: +% labkit_CurvatureMeasurement_app calibration callbacks. Input is a reason value. +% Output is true for reference-edit lifecycle reasons. +function tf = isReferenceEditReason(reason) +%ISREFERENCEEDITREASON Return true for reference edit lifecycle reasons. + + tf = false; + if ischar(reason) + text = string(reason); + elseif isstring(reason) && isscalar(reason) + text = reason; + else + return; + end + tf = any(text == ["set points", "add point", "delete point", ... + "move point", "clear points", "start", "finish"]); +end diff --git a/apps/image_measurement/curvature/private/lengthResultFromFit.m b/apps/image_measurement/curvature/private/lengthResultFromFit.m index 576ef29..a19cac9 100644 --- a/apps/image_measurement/curvature/private/lengthResultFromFit.m +++ b/apps/image_measurement/curvature/private/lengthResultFromFit.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function lengthResult = lengthResultFromFit(fit) %LENGTHRESULTFROMFIT Derive curve-length result from a fit result. % diff --git a/apps/image_measurement/curvature/private/lengthResultTableData.m b/apps/image_measurement/curvature/private/lengthResultTableData.m new file mode 100644 index 0000000..9765654 --- /dev/null +++ b/apps/image_measurement/curvature/private/lengthResultTableData.m @@ -0,0 +1,15 @@ +% App-owned curvature length table formatter. Expected caller: +% labkit_CurvatureMeasurement_app. Input is a length result struct. Output is +% metric/value cell data for the visible result table. +function data = lengthResultTableData(lengthResult) +%LENGTHRESULTTABLEDATA Return visible result table rows for length only. + + data = { ... + 'Curve length', sprintf('%.6g %s', lengthResult.length_show, lengthResult.unitLen); ... + 'Curve length px', sprintf('%.6g px', lengthResult.length_px); ... + 'Length points', sprintf('%d', lengthResult.pointCount); ... + sprintf('Pixels/%s', lengthResult.scaleUnit), sprintf('%.6g', lengthResult.px_per_unit); ... + 'Radius', '-'; ... + 'Curvature', '-'; ... + 'RMSE', '-'}; +end diff --git a/apps/image_measurement/curvature/private/optionValue.m b/apps/image_measurement/curvature/private/optionValue.m index fe3982b..1b1ecd4 100644 --- a/apps/image_measurement/curvature/private/optionValue.m +++ b/apps/image_measurement/curvature/private/optionValue.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function value = optionValue(opts, name, defaultValue) %OPTIONVALUE Read a scalar option field with default fallback. % % Expected caller: -% labkit_CurvatureMeasurement_app test handlers and app-private option +% labkit_CurvatureMeasurement_app workflow tests and app-private option % normalization helpers. % % Inputs/outputs: diff --git a/apps/image_measurement/curvature/private/plotAnchorResiduals.m b/apps/image_measurement/curvature/private/plotAnchorResiduals.m new file mode 100644 index 0000000..7360470 --- /dev/null +++ b/apps/image_measurement/curvature/private/plotAnchorResiduals.m @@ -0,0 +1,27 @@ +% App-owned curvature residual plotting helper. Expected caller: +% labkit_CurvatureMeasurement_app overlay rendering. Inputs are axes, anchor +% points, and a fit result struct. Draws residual segments and has no other side +% effects. +function plotAnchorResiduals(ax, points, fit) +%PLOTANCHORRESIDUALS Plot anchor-to-circle residual segments. + + dx = points(:, 1) - fit.xc_px; + dy = points(:, 2) - fit.yc_px; + radii = hypot(dx, dy); + valid = isfinite(radii) & radii > eps; + if ~any(valid) + return; + end + + circleX = fit.xc_px + fit.R_px .* dx(valid) ./ radii(valid); + circleY = fit.yc_px + fit.R_px .* dy(valid) ./ radii(valid); + anchorX = points(valid, 1); + anchorY = points(valid, 2); + xSegments = [anchorX.'; circleX.'; NaN(1, numel(circleX))]; + ySegments = [anchorY.'; circleY.'; NaN(1, numel(circleY))]; + plot(ax, xSegments(:), ySegments(:), '--', ... + 'Color', [1 0.9 0], ... + 'LineWidth', 1.2, ... + 'HitTest', 'off', ... + 'DisplayName', 'anchor residuals'); +end diff --git a/apps/image_measurement/curvature/private/plotDenseFitPoints.m b/apps/image_measurement/curvature/private/plotDenseFitPoints.m new file mode 100644 index 0000000..62f197c --- /dev/null +++ b/apps/image_measurement/curvature/private/plotDenseFitPoints.m @@ -0,0 +1,15 @@ +% App-owned curvature plotting helper. Expected caller: +% labkit_CurvatureMeasurement_app overlay rendering. Inputs are axes and a fit +% result struct. Draws dense fit points when densification added extra samples. +function plotDenseFitPoints(ax, fit) +%PLOTDENSEFITPOINTS Plot densified fit points for the curvature app. + + if numel(fit.xFit) <= numel(fit.xPix) + return; + end + plot(ax, fit.xFit, fit.yFit, '.', ... + 'Color', [0.95 0.2 0.95], ... + 'MarkerSize', 7, ... + 'HitTest', 'off', ... + 'DisplayName', 'dense fit points'); +end diff --git a/apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m b/apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m new file mode 100644 index 0000000..fffe5b4 --- /dev/null +++ b/apps/image_measurement/curvature/private/plotStaticCurveAnchorsView.m @@ -0,0 +1,33 @@ +% App-owned curvature static-anchor plotting helper. Expected caller: +% labkit_CurvatureMeasurement_app overlay rendering. Inputs are axes, anchor +% points, display curve, fit result, and dense-point visibility. Draws only into +% the supplied axes. +function plotStaticCurveAnchorsView(ax, points, curve, fit, showDense) +%PLOTSTATICCURVEANCHORSVIEW Draw inactive curve anchors and fit residuals. + + if isempty(points) + return; + end + + if ~isempty(curve) + plot(ax, curve(:, 1), curve(:, 2), '-', ... + 'Color', [0 0.45 0.95], ... + 'LineWidth', 1.5, ... + 'HitTest', 'off', ... + 'DisplayName', 'curve'); + end + if fit.ok + if showDense + plotDenseFitPoints(ax, fit); + end + plotAnchorResiduals(ax, points, fit); + end + plot(ax, points(:, 1), points(:, 2), 'o', ... + 'LineStyle', 'none', ... + 'Color', [1 0.85 0], ... + 'MarkerFaceColor', [0 0.45 0.95], ... + 'LineWidth', 1.2, ... + 'MarkerSize', 7, ... + 'HitTest', 'off', ... + 'DisplayName', 'anchors'); +end diff --git a/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m b/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m index b673a8c..f6b5aa2 100644 --- a/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m +++ b/apps/image_measurement/curvature/private/removeDuplicateNeighbors.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function [x, y] = removeDuplicateNeighbors(x, y, tol) %REMOVEDUPLICATENEIGHBORS Remove consecutive duplicate curve points. % diff --git a/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m b/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m index 3ba2556..9ef14ef 100644 --- a/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m +++ b/apps/image_measurement/curvature/private/scaleOptionsFromStruct.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function calibration = scaleOptionsFromStruct(opts) %SCALEOPTIONSFROMSTRUCT Normalize test and app scale options. % % Expected caller: -% labkit_CurvatureMeasurement_app __labkit_test__ handlers. +% labkit_CurvatureMeasurement_app workflow tests. % % Inputs/outputs: % Option struct with current and legacy scale fields. Returns a diff --git a/apps/image_measurement/curvature/private/ternary.m b/apps/image_measurement/curvature/private/ternary.m new file mode 100644 index 0000000..a144988 --- /dev/null +++ b/apps/image_measurement/curvature/private/ternary.m @@ -0,0 +1,12 @@ +% App-owned curvature conditional helper. Expected caller: +% labkit_CurvatureMeasurement_app UI state updates. Inputs are condition and two +% values. Output is the selected value. This helper has no side effects. +function value = ternary(condition, trueValue, falseValue) +%TERNARY Return trueValue or falseValue from a scalar condition. + + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/image_measurement/curvature/private/zoomAxesAtPoint.m b/apps/image_measurement/curvature/private/zoomAxesAtPoint.m new file mode 100644 index 0000000..4a8547a --- /dev/null +++ b/apps/image_measurement/curvature/private/zoomAxesAtPoint.m @@ -0,0 +1,34 @@ +% App-owned curvature axes zoom helper. Expected caller: +% labkit_CurvatureMeasurement_app scroll handling. Inputs are axes, pointer +% point, scroll count, and image size. Side effects are limited to axes limits. +function zoomAxesAtPoint(ax, x, y, scrollCount, imageSize) +%ZOOMAXESATPOINT Zoom image axes around a pointer location. + + if scrollCount == 0 + return; + end + + fullX = [0.5, imageSize(2) + 0.5]; + fullY = [0.5, imageSize(1) + 0.5]; + zoomFactor = 1.20 ^ scrollCount; + + currentX = ax.XLim; + currentY = ax.YLim; + newWidth = diff(currentX) * zoomFactor; + newHeight = diff(currentY) * zoomFactor; + + minSpan = 10; + newWidth = min(max(newWidth, minSpan), diff(fullX)); + newHeight = min(max(newHeight, minSpan), diff(fullY)); + + xFrac = (x - currentX(1)) / max(eps, diff(currentX)); + yFrac = (y - currentY(1)) / max(eps, diff(currentY)); + xFrac = min(max(xFrac, 0), 1); + yFrac = min(max(yFrac, 0), 1); + + newX = [x - xFrac * newWidth, x + (1 - xFrac) * newWidth]; + newY = [y - yFrac * newHeight, y + (1 - yFrac) * newHeight]; + + ax.XLim = clampLimits(newX, fullX); + ax.YLim = clampLimits(newY, fullY); +end diff --git a/apps/image_measurement/focus_stack/focusStackWorkflow.m b/apps/image_measurement/focus_stack/focusStackWorkflow.m new file mode 100644 index 0000000..2af5486 --- /dev/null +++ b/apps/image_measurement/focus_stack/focusStackWorkflow.m @@ -0,0 +1,23 @@ +function varargout = focusStackWorkflow(command, varargin) +%FOCUSSTACKWORKFLOW Dispatch app-owned focus-stack helpers. +% Expected caller: focus-stack app tests and migration-time workflow checks. +% Inputs are a workflow command plus command-specific arguments. Outputs match +% the selected app-private helper. This helper has no file side effects. + + switch string(command) + case "computeFocusStack" + varargout{1} = computeFocusStack(varargin{1}, varargin{2}); + case "buildFocusStackSummaryTable" + varargout{1} = buildFocusStackSummaryTable(varargin{1}, ... + string(varargin{2})); + case "findFocusStackImages" + varargout{1} = findFocusStackImages(string(varargin{1})); + case "selectedFocusImagePaths" + varargout{1} = selectedFocusImagePaths(varargin{1}, varargin{2}); + case "alignFocusStackImages" + [varargout{1:nargout}] = alignFocusStackImages(varargin{1}); + otherwise + error('labkit:FocusStack:UnknownWorkflowCommand', ... + 'Unknown focus-stack workflow helper command: %s.', command); + end +end diff --git a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m index b473d13..1131ccd 100644 --- a/apps/image_measurement/focus_stack/labkit_FocusStack_app.m +++ b/apps/image_measurement/focus_stack/labkit_FocusStack_app.m @@ -2,7 +2,7 @@ %LABKIT_FOCUSSTACK_APP Fuse a focus image stack into one all-in-focus image. [requestHandled, requestOutputs, debugLog] = labkit.ui.app.dispatchRequest( ... - 'labkit_FocusStack_app', varargin, nargout, focusStackAppTestHandlers()); + 'labkit_FocusStack_app', varargin, nargout); if requestHandled varargout = requestOutputs; return; @@ -446,236 +446,3 @@ function showError(titleText, message) uialert(fig, message, titleText); end end - -function handlers = focusStackAppTestHandlers() - handlers = struct( ... - 'command', {'computeFocusStack', 'buildFocusStackSummaryTable', 'findFocusStackImages', 'alignFocusStackImages', 'selectedFocusImagePaths'}, ... - 'minArgs', {2, 2, 1, 1, 2}, ... - 'maxArgs', {2, 2, 1, 1, 2}, ... - 'maxOutputs', {1, 1, 1, 2, 1}, ... - 'run', {@runComputeFocusStack, @runBuildFocusStackSummaryTable, @runFindFocusStackImages, @runAlignFocusStackImages, @runSelectedFocusImagePaths}); -end - -function outputs = runComputeFocusStack(args) - outputs = {computeFocusStack(args{1}, args{2})}; -end - -function outputs = runBuildFocusStackSummaryTable(args) - outputs = {buildFocusStackSummaryTable(args{1}, string(args{2}))}; -end - -function outputs = runFindFocusStackImages(args) - outputs = {findFocusStackImages(string(args{1}))}; -end - -function outputs = runAlignFocusStackImages(args) - [alignedImages, lines] = alignFocusStackImages(args{1}); - outputs = {alignedImages, lines}; -end - -function outputs = runSelectedFocusImagePaths(args) - outputs = {selectedFocusImagePaths(args{1}, args{2})}; -end - -function filter = focusImageDialogFilter() - filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... - 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; -end - -function paths = findFocusStackImages(folder) - if strlength(string(folder)) == 0 || exist(folder, 'dir') ~= 7 - error('labkit_FocusStack_app:FolderNotFound', ... - 'Focus image folder does not exist.'); - end - - entries = dir(folder); - keep = false(numel(entries), 1); - for k = 1:numel(entries) - entry = entries(k); - if entry.isdir - continue; - end - keep(k) = isSupportedFocusImagePath(entry.name); - end - - entries = entries(keep); - - paths = strings(numel(entries), 1); - for k = 1:numel(entries) - paths(k) = string(fullfile(folder, entries(k).name)); - end - paths = sortFocusStackPathsByName(paths); - if numel(paths) < 2 - error('labkit_FocusStack_app:NotEnoughImages', ... - 'Focus stacking requires at least two image files in the selected folder.'); - end -end - -function paths = selectedFocusImagePaths(files, folder) - if isequal(files, 0) || isequal(folder, 0) - paths = strings(0, 1); - return; - end - - if iscell(files) - names = string(files(:)); - else - names = string(files); - names = names(:); - end - names = names(strlength(names) > 0); - if isempty(names) - error('labkit_FocusStack_app:NoImagesSelected', ... - 'Select at least one image file.'); - end - - folder = string(folder); - paths = strings(numel(names), 1); - for k = 1:numel(names) - paths(k) = string(fullfile(folder, names(k))); - end - paths = sortFocusStackPathsByName(paths); - assertSupportedFocusImagePaths(paths); -end - -function images = readFocusStackImages(paths) - paths = string(paths(:)); - if isempty(paths) - error('labkit_FocusStack_app:NoImagesSelected', ... - 'Select at least one image file.'); - end - assertSupportedFocusImagePaths(paths); - - images = cell(numel(paths), 1); - for k = 1:numel(paths) - if exist(paths(k), 'file') ~= 2 - error('labkit_FocusStack_app:ImageFileNotFound', ... - 'Image file does not exist: %s', char(paths(k))); - end - images{k} = imread(paths(k)); - end -end - -function assertSupportedFocusImagePaths(paths) - for k = 1:numel(paths) - if ~isSupportedFocusImagePath(paths(k)) - error('labkit_FocusStack_app:UnsupportedImageFile', ... - 'Unsupported image file type: %s', char(paths(k))); - end - end -end - -function tf = isSupportedFocusImagePath(pathValue) - [~, ~, ext] = fileparts(char(pathValue)); - tf = any(strcmpi(ext, supportedFocusImageExtensions())); -end - -function extensions = supportedFocusImageExtensions() - extensions = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; -end - -function paths = sortFocusStackPathsByName(paths) - paths = string(paths(:)); - names = strings(numel(paths), 1); - for k = 1:numel(paths) - [~, base, ext] = fileparts(char(paths(k))); - names(k) = lower(string([base ext])); - end - [~, order] = sort(names); - paths = paths(order); -end - -function data = initialResultTable() - data = { ... - 'Input images', '-'; ... - 'Image size', '-'; ... - 'Detail scale', '-'; ... - 'Blend radius', '-'; ... - 'Uncertain blend', '-'; ... - 'Mean confidence', '-'; ... - 'Dominant source', '-'}; -end - -function data = focusStackResultTableData(result) - [dominantCoverage, dominantIndex] = max(result.focusCoverage); - data = { ... - 'Input images', sprintf('%d', result.inputCount); ... - 'Image size', sprintf('%d x %d px', result.imageWidth, result.imageHeight); ... - 'Detail scale', sprintf('%d px', result.focusWindow); ... - 'Blend radius', sprintf('%d px', result.smoothRadius); ... - 'Uncertain blend', sprintf('%.1f%%', 100 * result.minConfidence); ... - 'Mean confidence', sprintf('%.4f', result.meanConfidence); ... - 'Dominant source', sprintf('%d (%.1f%%)', dominantIndex, 100 * dominantCoverage)}; -end - -function lines = focusStackDetails(result, paths, registrationLines) - lines = { ... - sprintf('Method: %s', result.method), ... - sprintf('Fused size: %d x %d px, channels: %d', ... - result.imageWidth, result.imageHeight, result.channelCount), ... - sprintf('Images resized to first image: %d', result.resizedCount), ... - sprintf('Detail scale: %d px; blend radius: %d px; uncertain blend: %.1f%%', ... - result.focusWindow, result.smoothRadius, 100 * result.minConfidence), ... - 'Selected pixel coverage by source:'}; - names = displayImageNamesForDetails(paths, result.inputCount); - for k = 1:result.inputCount - lines{end+1} = sprintf(' %d. %s: %.2f%%', ... - k, names{k}, 100 * result.focusCoverage(k)); %#ok - end - if ~isempty(registrationLines) - lines{end+1} = 'Registration:'; %#ok - lines = [lines, registrationLines(:).']; %#ok - end -end - -function names = displayImageNamesForDetails(paths, count) - paths = string(paths(:)); - names = cell(count, 1); - for k = 1:count - if k <= numel(paths) - names{k} = displayNameFromPath(paths(k)); - else - names{k} = sprintf('slice_%03d', k); - end - end -end - -function names = displayImageNames(paths) - paths = string(paths(:)); - names = cell(numel(paths), 1); - for k = 1:numel(paths) - names{k} = displayNameFromPath(paths(k)); - end -end - -function rgb = focusIndexRgb(focusIndex, imageCount) - imageCount = max(1, double(imageCount)); - cmap = parula(max(imageCount, 2)); - idx = double(focusIndex); - idx(~isfinite(idx) | idx < 1) = 1; - idx(idx > imageCount) = imageCount; - rgb = zeros(size(idx, 1), size(idx, 2), 3); - for k = 1:imageCount - mask = idx == k; - for c = 1:3 - channel = rgb(:, :, c); - channel(mask) = cmap(k, c); - rgb(:, :, c) = channel; - end - end -end - -function img = previewImage(img) - img = im2double(img); - if ndims(img) == 3 && size(img, 3) > 3 - img = img(:, :, 1:3); - end -end - -function value = ternary(condition, trueValue, falseValue) - if condition - value = trueValue; - else - value = falseValue; - end -end diff --git a/apps/image_measurement/focus_stack/private/alignFocusStackImages.m b/apps/image_measurement/focus_stack/private/alignFocusStackImages.m index 4c3e5db..03a62ff 100644 --- a/apps/image_measurement/focus_stack/private/alignFocusStackImages.m +++ b/apps/image_measurement/focus_stack/private/alignFocusStackImages.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function [alignedImages, lines] = alignFocusStackImages(images) %ALIGNFOCUSSTACKIMAGES Align focus-stack images for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app run callback and __labkit_test__ handler. +% labkit_FocusStack_app run callback and workflow tests. % % Inputs/outputs: % Cell array or numeric stack of images. Returns images aligned to the diff --git a/apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m b/apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m new file mode 100644 index 0000000..5a055b3 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/assertSupportedFocusImagePaths.m @@ -0,0 +1,16 @@ +% App-owned focus-stack extension validator. Expected caller: focus-stack app +% private loading helpers. Input is a path vector. Throws on unsupported image +% extensions and has no side effects. +function assertSupportedFocusImagePaths(paths) +%ASSERTSUPPORTEDFOCUSIMAGEPATHS Validate focus-stack image path extensions. +% Expected caller: focus-stack app private loading helpers. Input is a path +% vector. This helper throws on unsupported image extensions and has no side +% effects. + + for k = 1:numel(paths) + if ~isSupportedFocusImagePath(paths(k)) + error('labkit_FocusStack_app:UnsupportedImageFile', ... + 'Unsupported image file type: %s', char(paths(k))); + end + end +end diff --git a/apps/image_measurement/focus_stack/private/boxMean2.m b/apps/image_measurement/focus_stack/private/boxMean2.m index 0b7c65d..c315564 100644 --- a/apps/image_measurement/focus_stack/private/boxMean2.m +++ b/apps/image_measurement/focus_stack/private/boxMean2.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function meanImage = boxMean2(imageData, windowSize) %BOXMEAN2 Compute a normalized box mean for focus-stack helpers. % diff --git a/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m b/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m index e37fac6..a6a2028 100644 --- a/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m +++ b/apps/image_measurement/focus_stack/private/buildFocusStackSummaryTable.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function T = buildFocusStackSummaryTable(result, paths) %BUILDFOCUSSTACKSUMMARYTABLE Build summary CSV table for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app export callback and __labkit_test__ handler. +% labkit_FocusStack_app export callback and workflow tests. % % Inputs/outputs: % Completed focus-stack result and source image paths. Returns the app-owned diff --git a/apps/image_measurement/focus_stack/private/computeFocusStack.m b/apps/image_measurement/focus_stack/private/computeFocusStack.m index 5a0ff0a..3cf1663 100644 --- a/apps/image_measurement/focus_stack/private/computeFocusStack.m +++ b/apps/image_measurement/focus_stack/private/computeFocusStack.m @@ -1,8 +1,11 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function result = computeFocusStack(images, opts) %COMPUTEFOCUSSTACK Fuse focus-stack images for labkit_FocusStack_app. % % Expected caller: -% labkit_FocusStack_app run callback and __labkit_test__ handler. +% labkit_FocusStack_app run callback and workflow tests. % % Inputs/outputs: % Cell array or numeric stack of images plus fusion options. Returns the diff --git a/apps/image_measurement/focus_stack/private/displayImageNames.m b/apps/image_measurement/focus_stack/private/displayImageNames.m new file mode 100644 index 0000000..284d02e --- /dev/null +++ b/apps/image_measurement/focus_stack/private/displayImageNames.m @@ -0,0 +1,12 @@ +% App-owned focus-stack display-name helper. Expected caller: +% labkit_FocusStack_app list refresh. Input is a path vector. Output is a cell +% column of display names. This helper has no side effects. +function names = displayImageNames(paths) +%DISPLAYIMAGENAMES Return display names for focus-stack paths. + + paths = string(paths(:)); + names = cell(numel(paths), 1); + for k = 1:numel(paths) + names{k} = displayNameFromPath(paths(k)); + end +end diff --git a/apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m b/apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m new file mode 100644 index 0000000..c51616c --- /dev/null +++ b/apps/image_measurement/focus_stack/private/displayImageNamesForDetails.m @@ -0,0 +1,16 @@ +% App-owned focus-stack display-name helper. Expected caller: +% focusStackDetails. Inputs are source paths and expected count. Output is a +% cell column of display names with synthetic fallbacks for missing paths. +function names = displayImageNamesForDetails(paths, count) +%DISPLAYIMAGENAMESFORDETAILS Return detail display names for stack sources. + + paths = string(paths(:)); + names = cell(count, 1); + for k = 1:count + if k <= numel(paths) + names{k} = displayNameFromPath(paths(k)); + else + names{k} = sprintf('slice_%03d', k); + end + end +end diff --git a/apps/image_measurement/focus_stack/private/displayNameFromPath.m b/apps/image_measurement/focus_stack/private/displayNameFromPath.m index faf02fc..c7bf09d 100644 --- a/apps/image_measurement/focus_stack/private/displayNameFromPath.m +++ b/apps/image_measurement/focus_stack/private/displayNameFromPath.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function name = displayNameFromPath(pathValue) %DISPLAYNAMEFROMPATH Return the app display name for a source image path. % diff --git a/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m b/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m index 6227f1d..3ed6699 100644 --- a/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m +++ b/apps/image_measurement/focus_stack/private/emptyFocusStackResult.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function result = emptyFocusStackResult() %EMPTYFOCUSSTACKRESULT Return default result for labkit_FocusStack_app. % diff --git a/apps/image_measurement/focus_stack/private/findFocusStackImages.m b/apps/image_measurement/focus_stack/private/findFocusStackImages.m new file mode 100644 index 0000000..973715f --- /dev/null +++ b/apps/image_measurement/focus_stack/private/findFocusStackImages.m @@ -0,0 +1,36 @@ +% App-owned focus-stack folder discovery helper. Expected caller: +% labkit_FocusStack_app and focusStackWorkflow. Input is a folder path. Output +% is a sorted string column of supported image paths. Reads directory metadata +% only and has no write side effects. +function paths = findFocusStackImages(folder) +%FINDFOCUSSTACKIMAGES Find supported focus-stack image files in a folder. +% Expected caller: labkit_FocusStack_app and focusStackWorkflow. Input is a +% folder path. Output is a sorted string column of supported image file paths. +% This helper reads directory metadata only and has no write side effects. + + if strlength(string(folder)) == 0 || exist(folder, 'dir') ~= 7 + error('labkit_FocusStack_app:FolderNotFound', ... + 'Focus image folder does not exist.'); + end + + entries = dir(folder); + keep = false(numel(entries), 1); + for k = 1:numel(entries) + entry = entries(k); + if entry.isdir + continue; + end + keep(k) = isSupportedFocusImagePath(entry.name); + end + entries = entries(keep); + + paths = strings(numel(entries), 1); + for k = 1:numel(entries) + paths(k) = string(fullfile(folder, entries(k).name)); + end + paths = sortFocusStackPathsByName(paths); + if numel(paths) < 2 + error('labkit_FocusStack_app:NotEnoughImages', ... + 'Focus stacking requires at least two image files in the selected folder.'); + end +end diff --git a/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m b/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m index aa9ed6f..5014d94 100644 --- a/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m +++ b/apps/image_measurement/focus_stack/private/focusFusionPresetSettings.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function settings = focusFusionPresetSettings(preset) %FOCUSFUSIONPRESETSETTINGS Return preset options for labkit_FocusStack_app. % diff --git a/apps/image_measurement/focus_stack/private/focusImageDialogFilter.m b/apps/image_measurement/focus_stack/private/focusImageDialogFilter.m new file mode 100644 index 0000000..198eb68 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/focusImageDialogFilter.m @@ -0,0 +1,9 @@ +% App-owned focus-stack file-dialog filter helper. Expected caller: +% labkit_FocusStack_app open-files callback. Output is the uigetfile filter +% cell array. This helper has no side effects. +function filter = focusImageDialogFilter() +%FOCUSIMAGEDIALOGFILTER Return the supported focus image dialog filter. + + filter = {'*.png;*.jpg;*.jpeg;*.tif;*.tiff;*.bmp', ... + 'Image files (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.bmp)'}; +end diff --git a/apps/image_measurement/focus_stack/private/focusIndexRgb.m b/apps/image_measurement/focus_stack/private/focusIndexRgb.m new file mode 100644 index 0000000..13efd9d --- /dev/null +++ b/apps/image_measurement/focus_stack/private/focusIndexRgb.m @@ -0,0 +1,21 @@ +% App-owned focus-stack preview color helper. Expected caller: +% labkit_FocusStack_app preview refresh. Inputs are focus index map and image +% count. Output is an RGB double image. This helper has no side effects. +function rgb = focusIndexRgb(focusIndex, imageCount) +%FOCUSINDEXRGB Convert focus-index map to an RGB preview image. + + imageCount = max(1, double(imageCount)); + cmap = parula(max(imageCount, 2)); + idx = double(focusIndex); + idx(~isfinite(idx) | idx < 1) = 1; + idx(idx > imageCount) = imageCount; + rgb = zeros(size(idx, 1), size(idx, 2), 3); + for k = 1:imageCount + mask = idx == k; + for c = 1:3 + channel = rgb(:, :, c); + channel(mask) = cmap(k, c); + rgb(:, :, c) = channel; + end + end +end diff --git a/apps/image_measurement/focus_stack/private/focusStackDetails.m b/apps/image_measurement/focus_stack/private/focusStackDetails.m new file mode 100644 index 0000000..4e12721 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/focusStackDetails.m @@ -0,0 +1,24 @@ +% App-owned focus-stack summary text helper. Expected caller: +% labkit_FocusStack_app result refresh. Inputs are result, source paths, and +% registration lines. Output is a cell row of detail strings. +function lines = focusStackDetails(result, paths, registrationLines) +%FOCUSSTACKDETAILS Return user-facing focus-stack detail lines. + + lines = { ... + sprintf('Method: %s', result.method), ... + sprintf('Fused size: %d x %d px, channels: %d', ... + result.imageWidth, result.imageHeight, result.channelCount), ... + sprintf('Images resized to first image: %d', result.resizedCount), ... + sprintf('Detail scale: %d px; blend radius: %d px; uncertain blend: %.1f%%', ... + result.focusWindow, result.smoothRadius, 100 * result.minConfidence), ... + 'Selected pixel coverage by source:'}; + names = displayImageNamesForDetails(paths, result.inputCount); + for k = 1:result.inputCount + lines{end+1} = sprintf(' %d. %s: %.2f%%', ... + k, names{k}, 100 * result.focusCoverage(k)); %#ok + end + if ~isempty(registrationLines) + lines{end+1} = 'Registration:'; %#ok + lines = [lines, registrationLines(:).']; %#ok + end +end diff --git a/apps/image_measurement/focus_stack/private/focusStackResultTableData.m b/apps/image_measurement/focus_stack/private/focusStackResultTableData.m new file mode 100644 index 0000000..6483fd1 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/focusStackResultTableData.m @@ -0,0 +1,16 @@ +% App-owned focus-stack visible table formatter. Expected caller: +% labkit_FocusStack_app. Input is a focus-stack result struct. Output is +% metric/value cell table data. This helper has no side effects. +function data = focusStackResultTableData(result) +%FOCUSSTACKRESULTTABLEDATA Return visible focus-stack result table rows. + + [dominantCoverage, dominantIndex] = max(result.focusCoverage); + data = { ... + 'Input images', sprintf('%d', result.inputCount); ... + 'Image size', sprintf('%d x %d px', result.imageWidth, result.imageHeight); ... + 'Detail scale', sprintf('%d px', result.focusWindow); ... + 'Blend radius', sprintf('%d px', result.smoothRadius); ... + 'Uncertain blend', sprintf('%.1f%%', 100 * result.minConfidence); ... + 'Mean confidence', sprintf('%.4f', result.meanConfidence); ... + 'Dominant source', sprintf('%d (%.1f%%)', dominantIndex, 100 * dominantCoverage)}; +end diff --git a/apps/image_measurement/focus_stack/private/initialResultTable.m b/apps/image_measurement/focus_stack/private/initialResultTable.m new file mode 100644 index 0000000..63f3149 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/initialResultTable.m @@ -0,0 +1,15 @@ +% App-owned focus-stack result table initializer. Expected caller: +% labkit_FocusStack_app. Output is default metric/value cell table data. This +% helper has no side effects. +function data = initialResultTable() +%INITIALRESULTTABLE Return default focus-stack result table rows. + + data = { ... + 'Input images', '-'; ... + 'Image size', '-'; ... + 'Detail scale', '-'; ... + 'Blend radius', '-'; ... + 'Uncertain blend', '-'; ... + 'Mean confidence', '-'; ... + 'Dominant source', '-'}; +end diff --git a/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m b/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m new file mode 100644 index 0000000..2020960 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/isSupportedFocusImagePath.m @@ -0,0 +1,11 @@ +% App-owned focus-stack extension predicate. Expected caller: focus-stack app +% private loading helpers. Input is a path or filename. Output is a scalar +% logical based on the file extension only. +function tf = isSupportedFocusImagePath(pathValue) +%ISSUPPORTEDFOCUSIMAGEPATH Return true for supported focus-stack image files. +% Expected caller: focus-stack app private loading helpers. Input is a path or +% filename. Output is a scalar logical based on the file extension only. + + [~, ~, ext] = fileparts(char(pathValue)); + tf = any(strcmpi(ext, supportedFocusImageExtensions())); +end diff --git a/apps/image_measurement/focus_stack/private/normalizeGray.m b/apps/image_measurement/focus_stack/private/normalizeGray.m index 662b6e1..aa4a343 100644 --- a/apps/image_measurement/focus_stack/private/normalizeGray.m +++ b/apps/image_measurement/focus_stack/private/normalizeGray.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function gray = normalizeGray(imageData) %NORMALIZEGRAY Convert focus-stack image data to normalized grayscale. % diff --git a/apps/image_measurement/focus_stack/private/normalizeImageCell.m b/apps/image_measurement/focus_stack/private/normalizeImageCell.m index 2ee736a..a26c2ad 100644 --- a/apps/image_measurement/focus_stack/private/normalizeImageCell.m +++ b/apps/image_measurement/focus_stack/private/normalizeImageCell.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function images = normalizeImageCell(images) %NORMALIZEIMAGECELL Normalize focus-stack input image containers. % diff --git a/apps/image_measurement/focus_stack/private/previewImage.m b/apps/image_measurement/focus_stack/private/previewImage.m new file mode 100644 index 0000000..8a771d2 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/previewImage.m @@ -0,0 +1,11 @@ +% App-owned focus-stack preview normalization helper. Expected caller: +% labkit_FocusStack_app preview refresh. Input is an image array. Output is a +% double preview image with at most three channels. +function img = previewImage(img) +%PREVIEWIMAGE Normalize an image for focus-stack preview display. + + img = im2double(img); + if ndims(img) == 3 && size(img, 3) > 3 + img = img(:, :, 1:3); + end +end diff --git a/apps/image_measurement/focus_stack/private/readFocusStackImages.m b/apps/image_measurement/focus_stack/private/readFocusStackImages.m new file mode 100644 index 0000000..6296f2e --- /dev/null +++ b/apps/image_measurement/focus_stack/private/readFocusStackImages.m @@ -0,0 +1,23 @@ +% App-owned focus-stack image loading helper. Expected caller: +% labkit_FocusStack_app run callback. Input is a vector of image paths. Output +% is a cell column of image arrays. Reads image files and has no write side +% effects. +function images = readFocusStackImages(paths) +%READFOCUSSTACKIMAGES Read selected focus-stack images from disk. + + paths = string(paths(:)); + if isempty(paths) + error('labkit_FocusStack_app:NoImagesSelected', ... + 'Select at least one image file.'); + end + assertSupportedFocusImagePaths(paths); + + images = cell(numel(paths), 1); + for k = 1:numel(paths) + if exist(paths(k), 'file') ~= 2 + error('labkit_FocusStack_app:ImageFileNotFound', ... + 'Image file does not exist: %s', char(paths(k))); + end + images{k} = imread(paths(k)); + end +end diff --git a/apps/image_measurement/focus_stack/private/resizeImageToReference.m b/apps/image_measurement/focus_stack/private/resizeImageToReference.m index 59b1480..c250d3b 100644 --- a/apps/image_measurement/focus_stack/private/resizeImageToReference.m +++ b/apps/image_measurement/focus_stack/private/resizeImageToReference.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function imageOut = resizeImageToReference(imageIn, referenceSize) %RESIZEIMAGETOREFERENCE Resize focus-stack image data to a reference frame. % diff --git a/apps/image_measurement/focus_stack/private/resizeImageToSize.m b/apps/image_measurement/focus_stack/private/resizeImageToSize.m index b32a4b8..6b76699 100644 --- a/apps/image_measurement/focus_stack/private/resizeImageToSize.m +++ b/apps/image_measurement/focus_stack/private/resizeImageToSize.m @@ -1,3 +1,6 @@ +% App-private image measurement helper. Expected caller: owning app callbacks +% and workflow tests. Inputs, outputs, and side effects are +% documented with the helper function below. function imageOut = resizeImageToSize(imageIn, targetSize) %RESIZEIMAGETOSIZE Resize focus-stack image data to an explicit size. % diff --git a/apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m b/apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m new file mode 100644 index 0000000..f473adf --- /dev/null +++ b/apps/image_measurement/focus_stack/private/selectedFocusImagePaths.m @@ -0,0 +1,35 @@ +% App-owned focus-stack selected-file normalization helper. Expected caller: +% labkit_FocusStack_app and focusStackWorkflow. Inputs are raw uigetfile values. +% Output is a sorted string column of image paths. Validates extensions only and +% has no file side effects. +function paths = selectedFocusImagePaths(files, folder) +%SELECTEDFOCUSIMAGEPATHS Normalize manually selected focus-stack image paths. +% Expected caller: labkit_FocusStack_app and focusStackWorkflow. Inputs are the +% raw uigetfile files value and folder value. Output is a sorted string column. +% This helper validates extensions only and has no file side effects. + + if isequal(files, 0) || isequal(folder, 0) + paths = strings(0, 1); + return; + end + + if iscell(files) + names = string(files(:)); + else + names = string(files); + names = names(:); + end + names = names(strlength(names) > 0); + if isempty(names) + error('labkit_FocusStack_app:NoImagesSelected', ... + 'Select at least one image file.'); + end + + folder = string(folder); + paths = strings(numel(names), 1); + for k = 1:numel(names) + paths(k) = string(fullfile(folder, names(k))); + end + paths = sortFocusStackPathsByName(paths); + assertSupportedFocusImagePaths(paths); +end diff --git a/apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m b/apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m new file mode 100644 index 0000000..c317e20 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/sortFocusStackPathsByName.m @@ -0,0 +1,17 @@ +% App-owned focus-stack path sorting helper. Expected caller: focus-stack app +% private loading helpers. Input is a path vector. Output is a string column +% sorted by base filename plus extension. +function paths = sortFocusStackPathsByName(paths) +%SORTFOCUSSTACKPATHSBYNAME Sort focus-stack paths by case-insensitive name. +% Expected caller: focus-stack app private loading helpers. Input is a path +% vector. Output is a string column sorted by base filename plus extension. + + paths = string(paths(:)); + names = strings(numel(paths), 1); + for k = 1:numel(paths) + [~, base, ext] = fileparts(char(paths(k))); + names(k) = lower(string([base ext])); + end + [~, order] = sort(names); + paths = paths(order); +end diff --git a/apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m b/apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m new file mode 100644 index 0000000..930d53d --- /dev/null +++ b/apps/image_measurement/focus_stack/private/supportedFocusImageExtensions.m @@ -0,0 +1,10 @@ +% App-owned focus-stack extension list helper. Expected caller: focus-stack app +% private loading helpers. Output is a cell array of lowercase extension strings +% and the helper has no side effects. +function extensions = supportedFocusImageExtensions() +%SUPPORTEDFOCUSIMAGEEXTENSIONS Return supported focus-stack image extensions. +% Expected caller: focus-stack app private loading helpers. Output is a cell +% array of lowercase extension strings. This helper has no side effects. + + extensions = {'.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp'}; +end diff --git a/apps/image_measurement/focus_stack/private/ternary.m b/apps/image_measurement/focus_stack/private/ternary.m new file mode 100644 index 0000000..81365f3 --- /dev/null +++ b/apps/image_measurement/focus_stack/private/ternary.m @@ -0,0 +1,12 @@ +% App-owned focus-stack conditional helper. Expected caller: +% labkit_FocusStack_app UI state refresh. Inputs are condition and two values. +% Output is the selected value. This helper has no side effects. +function value = ternary(condition, trueValue, falseValue) +%TERNARY Return trueValue or falseValue from a scalar condition. + + if condition + value = trueValue; + else + value = falseValue; + end +end diff --git a/apps/wearable/labkit_ECGPrint_app.m b/apps/wearable/labkit_ECGPrint_app.m index 0c11e7d..4dcf778 100644 --- a/apps/wearable/labkit_ECGPrint_app.m +++ b/apps/wearable/labkit_ECGPrint_app.m @@ -17,769 +17,11 @@ 'labkit_ECGPrint_app returns at most the app figure handle.'); end - S = struct(); - S.recording = []; - S.signal = []; - S.workingSignal = []; - S.filteredSignal = []; - S.events = []; - S.segments = []; - S.template = []; - S.measurements = []; - S.filepath = ""; - - opts = struct( ... - 'rightTitle', 'ECG Preview', ... - 'rightGridSize', [4 1], ... - 'rightRowHeight', {{'1.2x', '1x', '1x', '1x'}}, ... - 'rightRowSpacing', 8); - opts.tabs = [ ... - labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [6 1], ... - {140, 255, 120, 235, 100, 125}, ... - struct('resizeRows', [1 2 3 4 5])), ... - labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... - {210, '1x'}, ... - struct('resizeRows', 1)), ... - labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; - - ui = labkit.ui.app.createShell(struct( ... - 'title', 'ECG Signal Print + SNR Explorer', ... - 'position', [80 70 1480 880], ... - 'leftWidth', 410, ... - 'options', opts)); - fig = ui.fig; - layFA = ui.filesAnalysisGrid; - laySR = ui.summaryResultsGrid; - layLog = ui.logGrid; - - recordingPanel = labkit.ui.view.section(layFA, 'Recording', 1, [3 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... - 'columnWidth', {{135, '1x'}})); - recordingGrid = recordingPanel.grid; - - btnOpen = uibutton(recordingGrid, 'Text', 'Open recording', 'ButtonPushedFcn', @onOpenRecording); - btnOpen.Layout.Row = 1; - btnOpen.Layout.Column = [1 2]; - - txtFile = labkit.ui.view.form(recordingGrid, 'readonly', 'Value', 'No file loaded'); - txtFile.Layout.Row = 2; - txtFile.Layout.Column = [1 2]; - - btnPreviewHeader = uibutton(recordingGrid, 'Text', 'Preview file header', ... - 'ButtonPushedFcn', @onPreviewHeader); - btnPreviewHeader.Layout.Row = 3; - btnPreviewHeader.Layout.Column = [1 2]; - - importPanel = labkit.ui.view.section(layFA, 'Import Parsing', 2, [8 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 8)}, ... - 'columnWidth', {{135, '1x'}})); - importGrid = importPanel.grid; - - txtImportStatus = labkit.ui.view.form(importGrid, 'readonly', ... - 'Value', 'Open a recording to inspect import settings.'); - txtImportStatus.Layout.Row = 1; - txtImportStatus.Layout.Column = [1 2]; - - [lblHeaderLine, edtHeaderLine] = labkit.ui.view.form(importGrid, 'spinner', ... - 'CSV header line:', 'Value', 0, 'Limits', [0 Inf], 'Step', 1, ... - 'ValueChangedFcn', @onImportOptionChanged); - lblHeaderLine.Layout.Row = 2; - lblHeaderLine.Layout.Column = 1; - edtHeaderLine.Layout.Row = 2; - edtHeaderLine.Layout.Column = 2; - - [lblHasHeader, ddHasHeader] = labkit.ui.view.form(importGrid, 'dropdown', ... - 'CSV header:', ... - 'Items', {'Auto', 'Yes', 'No'}, ... - 'Value', 'Auto', ... - 'ValueChangedFcn', @onImportOptionChanged); - lblHasHeader.Layout.Row = 3; - lblHasHeader.Layout.Column = 1; - ddHasHeader.Layout.Row = 3; - ddHasHeader.Layout.Column = 2; - - [lblTimeColumn, edtTimeColumn] = labkit.ui.view.form(importGrid, 'edit', ... - 'Time column:', 'text', 'Value', '', ... - 'ValueChangedFcn', @onImportOptionChanged); - lblTimeColumn.Layout.Row = 4; - lblTimeColumn.Layout.Column = 1; - edtTimeColumn.Layout.Row = 4; - edtTimeColumn.Layout.Column = 2; - - [lblTimeUnit, ddTimeUnit] = labkit.ui.view.form(importGrid, 'dropdown', ... - 'Time unit:', ... - 'Items', {'Auto', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds'}, ... - 'Value', 'Auto', ... - 'ValueChangedFcn', @onImportOptionChanged); - lblTimeUnit.Layout.Row = 5; - lblTimeUnit.Layout.Column = 1; - ddTimeUnit.Layout.Row = 5; - ddTimeUnit.Layout.Column = 2; - - [lblSignalColumns, edtSignalColumns] = labkit.ui.view.form(importGrid, 'edit', ... - 'Signal columns:', 'text', 'Value', '', ... - 'ValueChangedFcn', @onImportOptionChanged); - lblSignalColumns.Layout.Row = 6; - lblSignalColumns.Layout.Column = 1; - edtSignalColumns.Layout.Row = 6; - edtSignalColumns.Layout.Column = 2; - - [lblFallbackFs, edtFallbackFs] = labkit.ui.view.form(importGrid, 'spinner', ... - 'Fallback Fs:', 'Value', 2000, 'Limits', [0 Inf], 'Step', 100, ... - 'ValueChangedFcn', @onImportOptionChanged); - lblFallbackFs.Layout.Row = 7; - lblFallbackFs.Layout.Column = 1; - edtFallbackFs.Layout.Row = 7; - edtFallbackFs.Layout.Column = 2; - - btnRefreshImport = uibutton(importGrid, 'Text', 'Parse / refresh file', ... - 'ButtonPushedFcn', @onRefreshImport); - btnRefreshImport.Layout.Row = 8; - btnRefreshImport.Layout.Column = [1 2]; - - channelPanel = labkit.ui.view.section(layFA, 'Channel + ROI', 3, [3 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... - 'columnWidth', {{135, '1x'}})); - channelGrid = channelPanel.grid; - - [lblChannel, ddChannel] = labkit.ui.view.form(channelGrid, 'dropdown', 'Channel:', ... - 'Items', {'(none)'}, 'Value', '(none)', 'ValueChangedFcn', @onChannelChanged); - lblChannel.Layout.Row = 1; - lblChannel.Layout.Column = 1; - ddChannel.Layout.Row = 1; - ddChannel.Layout.Column = 2; - - [lblStart, edtStart] = labkit.ui.view.form(channelGrid, 'spinner', ... - 'ROI start (s):', 'Value', 0, 'Limits', [0 Inf], 'Step', 1); - lblStart.Layout.Row = 2; - lblStart.Layout.Column = 1; - edtStart.Layout.Row = 2; - edtStart.Layout.Column = 2; - - [lblEnd, edtEnd] = labkit.ui.view.form(channelGrid, 'spinner', ... - 'ROI end (s):', 'Value', 0, 'Limits', [0 Inf], 'Step', 1); - lblEnd.Layout.Row = 3; - lblEnd.Layout.Column = 1; - edtEnd.Layout.Row = 3; - edtEnd.Layout.Column = 2; - - procPanel = labkit.ui.view.section(layFA, 'Signal Processing + SNR', 4, [9 2], ... - struct('rowHeight', {repmat({'fit'}, 1, 9)}, ... - 'columnWidth', {{135, '1x'}})); - procGrid = procPanel.grid; - - [lblLow, edtLow] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Bandpass low Hz:', 'Value', 0.5, 'Limits', [0 Inf], 'Step', 0.1); - lblLow.Layout.Row = 1; - lblLow.Layout.Column = 1; - edtLow.Layout.Row = 1; - edtLow.Layout.Column = 2; - - [lblHigh, edtHigh] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Bandpass high Hz:', 'Value', 40, 'Limits', [0 Inf], 'Step', 1); - lblHigh.Layout.Row = 2; - lblHigh.Layout.Column = 1; - edtHigh.Layout.Row = 2; - edtHigh.Layout.Column = 2; - - [lblPeakMethod, ddPeakMethod] = labkit.ui.view.form(procGrid, 'dropdown', ... - 'Peak method:', ... - 'Items', {'QRS streaming', 'Pan-Tompkins', 'Local peaks'}, ... - 'Value', 'QRS streaming'); - lblPeakMethod.Layout.Row = 3; - lblPeakMethod.Layout.Column = 1; - ddPeakMethod.Layout.Row = 3; - ddPeakMethod.Layout.Column = 2; - - [lblPeakDist, edtPeakDist] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Peak distance (s):', 'Value', 0.28, 'Limits', [0.01 Inf], 'Step', 0.01); - lblPeakDist.Layout.Row = 4; - lblPeakDist.Layout.Column = 1; - edtPeakDist.Layout.Row = 4; - edtPeakDist.Layout.Column = 2; - - [lblWin, edtWin] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Segment half win (s):', 'Value', 0.7, 'Limits', [0.01 Inf], 'Step', 0.05); - lblWin.Layout.Row = 5; - lblWin.Layout.Column = 1; - edtWin.Layout.Row = 5; - edtWin.Layout.Column = 2; - - [lblTopN, edtTopN] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Template top N:', 'Value', 30, 'Limits', [1 Inf], 'Step', 1); - lblTopN.Layout.Row = 6; - lblTopN.Layout.Column = 1; - edtTopN.Layout.Row = 6; - edtTopN.Layout.Column = 2; - - [lblSmooth, edtSmooth] = labkit.ui.view.form(procGrid, 'spinner', ... - 'Smooth beats:', 'Value', 15, 'Limits', [1 Inf], 'Step', 1, ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - lblSmooth.Layout.Row = 7; - lblSmooth.Layout.Column = 1; - edtSmooth.Layout.Row = 7; - edtSmooth.Layout.Column = 2; - - [lblView, ddTemplateView] = labkit.ui.view.form(procGrid, 'dropdown', ... - 'Template plot:', ... - 'Items', {'Template + residual band', 'Template + segments'}, ... - 'Value', 'Template + residual band', ... - 'ValueChangedFcn', @(~,~) refreshPlots()); - lblView.Layout.Row = 8; - lblView.Layout.Column = 1; - ddTemplateView.Layout.Row = 8; - ddTemplateView.Layout.Column = 2; - - btnAnalyze = uibutton(procGrid, 'Text', 'Analyze current ROI', ... - 'ButtonPushedFcn', @onAnalyze); - btnAnalyze.Layout.Row = 9; - btnAnalyze.Layout.Column = [1 2]; - - exportPanel = labkit.ui.view.section(layFA, 'Exports', 5, [2 1], ... - struct('rowHeight', {{'fit','fit'}})); - exportGrid = exportPanel.grid; - btnExportSegments = uibutton(exportGrid, 'Text', 'Export segment SNR CSV', ... - 'ButtonPushedFcn', @onExportSegments); - btnExportSegments.Layout.Row = 1; - btnExportOverlay = uibutton(exportGrid, 'Text', 'Export waveform PNG', ... - 'ButtonPushedFcn', @onExportWaveform); - btnExportOverlay.Layout.Row = 2; - - labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 6, { ... - '1. Open MAT/CSV data, select a numeric channel, and optionally set a time ROI.', ... - '2. Use File Header Preview and Import Parsing only when CSV/text auto-detection needs correction.', ... - '3. Analysis filters the selected channel with edge padding, then crops the filtered signal to the ROI for peak/SNR measurement.'}); - - summaryTable = uitable(laySR, 'ColumnName', {'Metric','Value'}, ... - 'Data', initialSummaryRows()); - labkit.ui.view.place(summaryTable, laySR, 1); - - previewUi = labkit.ui.view.panel(laySR, 'text', 'File Header Preview', 2, ... - {'Open a CSV/text file, then use Preview file header.'}); - txtFilePreview = previewUi.textArea; - - logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); - txtLog = logUi.textArea; - - ui.waveAxes = uiaxes(ui.rightGrid); - ui.waveAxes.Layout.Row = 1; - ui.noiseAxes = uiaxes(ui.rightGrid); - ui.noiseAxes.Layout.Row = 2; - ui.snrAxes = uiaxes(ui.rightGrid); - ui.snrAxes.Layout.Row = 3; - ui.templateAxes = uiaxes(ui.rightGrid); - ui.templateAxes.Layout.Row = 4; - - if debugLog.enabled - debugLog.attachTextLog(txtLog); - debugLog.trace('ECG print debug trace enabled.'); - debugLog.instrumentFigure(fig); - end - - resetAxes(); + fig = runECGPrintApp(debugLog); if nargout >= 1 varargout{1} = fig; end if nargout >= 2 varargout{2} = debugLog; end - - function onOpenRecording(~, ~) - [fn, fp] = uigetfile( ... - {'*.mat;*.csv;*.txt;*.tsv', 'Biosignal files (*.mat, *.csv, *.txt, *.tsv)'; ... - '*.*', 'All files'}, ... - 'Select biosignal recording'); - if isequal(fn, 0) - addLog('Recording selection cancelled.'); - return; - end - - S.filepath = string(fullfile(fp, fn)); - txtFile.Value = char(S.filepath); - clearParsedRecording(); - updateFilePreview(); - refreshImportParsing(false); - end - - function onRefreshImport(~, ~) - refreshImportParsing(true); - end - - function refreshImportParsing(showAlertOnFailure) - if nargin < 1 - showAlertOnFailure = true; - end - if strlength(S.filepath) == 0 - if showAlertOnFailure - showError('No recording selected', 'Open a recording before parsing.'); - else - txtImportStatus.Value = 'Open a recording before parsing.'; - end - return; - end - - txtImportStatus.Value = 'Parsing file...'; - selectedChannel = ""; - if ~isempty(ddChannel.Items) && ~strcmp(ddChannel.Value, '(none)') - selectedChannel = string(ddChannel.Value); - end - - importOpts = currentImportOptions(); - [recording, status] = labkit.biosignal.readRecording(char(S.filepath), importOpts); - if ~status.ok - clearParsedRecording(); - txtImportStatus.Value = char("Parse failed. Inspect header/settings, then refresh: " + status.message); - if showAlertOnFailure - showError('Could not parse recording', status.message); - else - addLog(sprintf('Automatic parse failed: %s', status.message)); - end - return; - end - - S.recording = recording; - channels = labkit.biosignal.listChannels(recording); - if isempty(channels) - clearParsedRecording(); - txtImportStatus.Value = 'Parse failed: no numeric signal channels were found.'; - if showAlertOnFailure - showError('Could not parse recording', 'No numeric signal channels were found.'); - end - return; - end - ddChannel.Items = channels; - if any(strcmp(channels, selectedChannel)) - ddChannel.Value = char(selectedChannel); - else - ddChannel.Value = channels{1}; - end - setCurrentChannel(ddChannel.Value); - txtImportStatus.Value = importStatusText(recording, numel(channels)); - addLog(sprintf('Parsed %d channel(s) from %s', numel(channels), char(S.filepath))); - end - - function onPreviewHeader(~, ~) - updateFilePreview(); - end - - function updateFilePreview() - if strlength(S.filepath) == 0 - txtFilePreview.Value = {'Open a CSV/text file, then use Preview file header.'}; - return; - end - txtFilePreview.Value = previewFileHeader(char(S.filepath), 18); - addLog(sprintf('Previewed file header: %s', char(S.filepath))); - end - - function onImportOptionChanged(~, ~) - if strlength(S.filepath) > 0 - txtImportStatus.Value = 'Import settings changed. Click Parse / refresh file.'; - end - end - - function clearParsedRecording() - S.recording = []; - S.signal = []; - S.workingSignal = []; - S.filteredSignal = []; - S.events = []; - S.segments = []; - S.template = []; - S.measurements = []; - ddChannel.Items = {'(none)'}; - ddChannel.Value = '(none)'; - edtStart.Value = 0; - edtEnd.Value = 0; - updateSummary(); - refreshPlots(); - end - - function optsOut = currentImportOptions() - optsOut = struct('fallbackFs', edtFallbackFs.Value); - if edtHeaderLine.Value > 0 - optsOut.headerLine = round(edtHeaderLine.Value); - end - switch string(ddHasHeader.Value) - case "Yes" - optsOut.hasHeader = true; - case "No" - optsOut.hasHeader = false; - end - if strlength(strtrim(string(edtTimeColumn.Value))) > 0 - optsOut.timeColumn = parseColumnSpec(edtTimeColumn.Value); - end - if string(ddTimeUnit.Value) ~= "Auto" - optsOut.timeUnit = ddTimeUnit.Value; - end - if strlength(strtrim(string(edtSignalColumns.Value))) > 0 - optsOut.signalColumns = parseColumnList(edtSignalColumns.Value); - end - end - - function onChannelChanged(~, ~) - if isempty(S.recording) || strcmp(ddChannel.Value, '(none)') - return; - end - setCurrentChannel(ddChannel.Value); - end - - function setCurrentChannel(channelName) - S.signal = labkit.biosignal.getChannel(S.recording, channelName); - S.workingSignal = S.signal; - S.filteredSignal = []; - S.events = []; - S.segments = []; - S.template = []; - S.measurements = []; - if ~isempty(S.signal.time) - edtStart.Value = 0; - edtEnd.Value = max(S.signal.time); - end - updateSummary(); - refreshPlots(); - end - - function onAnalyze(~, ~) - if isempty(S.signal) - showError('No channel selected', 'Open a recording and select a channel first.'); - return; - end - - try - timeRange = [edtStart.Value edtEnd.Value]; - highCut = min(edtHigh.Value, max(edtLow.Value + eps, 0.45 * S.signal.fs)); - filterSpec = struct('type', 'bandpass', 'cutoffHz', [edtLow.Value highCut]); - fullFiltered = labkit.biosignal.filterSignal(S.signal, filterSpec); - if timeRange(2) > timeRange(1) - S.workingSignal = labkit.biosignal.cropSignal(S.signal, timeRange); - S.filteredSignal = labkit.biosignal.cropSignal(fullFiltered, timeRange); - else - S.workingSignal = S.signal; - S.filteredSignal = fullFiltered; - end - peakOpts = struct('polarity', 'auto', ... - 'method', peakMethodValue(ddPeakMethod.Value), ... - 'minDistanceSec', edtPeakDist.Value, ... - 'thresholdStd', 2.8); - S.events = labkit.biosignal.detectEcgPeaks(S.filteredSignal, peakOpts); - halfWin = edtWin.Value; - S.segments = labkit.biosignal.segmentByEvents(S.filteredSignal, S.events, [-halfWin halfWin]); - S.template = labkit.biosignal.buildTemplate(S.segments, struct('topN', edtTopN.Value)); - S.measurements = labkit.biosignal.measureSegments(S.segments, S.template); - - addLog(sprintf('Filtered channel, then analyzed ROI with %s: %d peaks, %d valid segments.', ... - ddPeakMethod.Value, numel(S.events.index), size(S.segments.values, 2))); - updateSummary(); - refreshPlots(); - catch ME - showError('Analysis failed', ME.message); - end - end - - function onExportSegments(~, ~) - if isempty(S.measurements) || isempty(S.measurements.perSegment) - showError('No segment SNR', 'Analyze a signal before exporting segment SNR.'); - return; - end - [fn, fp] = uiputfile('ecg_segment_snr.csv', 'Export segment SNR CSV'); - if isequal(fn, 0) - addLog('Segment SNR export cancelled.'); - return; - end - writetable(analysisTable(), fullfile(fp, fn)); - addLog(sprintf('Exported segment SNR CSV: %s', fullfile(fp, fn))); - end - - function onExportWaveform(~, ~) - [fn, fp] = uiputfile('ecg_waveform.png', 'Export waveform PNG'); - if isequal(fn, 0) - addLog('Waveform export cancelled.'); - return; - end - exportgraphics(ui.waveAxes, fullfile(fp, fn), 'Resolution', 300); - addLog(sprintf('Exported waveform PNG: %s', fullfile(fp, fn))); - end - - function refreshPlots() - resetAxes(); - if isempty(S.workingSignal) - return; - end - - sig = S.workingSignal; - if ~isempty(S.filteredSignal) - sig = S.filteredSignal; - end - - ax = ui.waveAxes; - plot(ax, sig.time, sig.values, 'Color', [0.15 0.38 0.72], 'LineWidth', 1); - hold(ax, 'on'); - if ~isempty(S.events) && ~isempty(S.events.index) - scatter(ax, sig.time(S.events.index), sig.values(S.events.index), ... - 24, [0.85 0.25 0.15], 'filled'); - end - hold(ax, 'off'); - title(ax, 'Waveform + Peaks'); - xlabel(ax, 'Time (s)'); - ylabel(ax, char(sig.name)); - grid(ax, 'on'); - - if isempty(S.measurements) - return; - end - - T = analysisTable(); - smoothBeats = max(1, round(edtSmooth.Value)); - - noiseAx = ui.noiseAxes; - plot(noiseAx, T.EventTime, T.NoiseRMS, '.', 'MarkerSize', 12, ... - 'Color', [0.20 0.45 0.72]); - hold(noiseAx, 'on'); - plot(noiseAx, T.EventTime, movingMedian(T.NoiseRMS, smoothBeats), '-', ... - 'LineWidth', 1.5, 'Color', [0.05 0.20 0.45]); - hold(noiseAx, 'off'); - title(noiseAx, sprintf('Template Noise RMS Over Time | Smooth=%d beats', smoothBeats)); - xlabel(noiseAx, 'Time (s)'); - ylabel(noiseAx, 'Noise RMS'); - grid(noiseAx, 'on'); - - snrAx = ui.snrAxes; - plot(snrAx, T.EventTime, T.SNRdB, '.', 'MarkerSize', 12, ... - 'Color', [0.18 0.55 0.32]); - hold(snrAx, 'on'); - plot(snrAx, T.EventTime, movingMedian(T.SNRdB, smoothBeats), '-', ... - 'LineWidth', 1.5, 'Color', [0.05 0.32 0.16]); - hold(snrAx, 'off'); - title(snrAx, sprintf('Template SNR Over Time | Smooth=%d beats', smoothBeats)); - xlabel(snrAx, 'Time (s)'); - ylabel(snrAx, 'SNR (dB)'); - grid(snrAx, 'on'); - - refreshTemplatePlot(); - end - - function updateSummary() - summaryTable.Data = buildSummaryRows(); - end - - function refreshTemplatePlot() - ax = ui.templateAxes; - labkit.ui.view.draw(ax, 'reset', 'Template + Residual Band'); - xlabel(ax, 'Time from peak (s)'); - ylabel(ax, 'Amplitude'); - if isempty(S.segments) || isempty(S.template) || isempty(S.segments.values) - return; - end - - X = double(S.segments.values); - t = double(S.segments.timeOffset(:)); - template = double(S.template.values(:)); - if isempty(X) || isempty(template) - return; - end - - hold(ax, 'on'); - if strcmp(ddTemplateView.Value, 'Template + segments') - maxShow = min(40, size(X, 2)); - showIdx = unique(round(linspace(1, size(X, 2), maxShow))); - plot(ax, t, X(:, showIdx), 'Color', [0.78 0.84 0.92], 'LineWidth', 0.5); - title(ax, 'Template + Segments'); - else - residStd = std(X - template, 0, 2, 'omitnan'); - upper = template + residStd; - lower = template - residStd; - fill(ax, [t; flipud(t)], [upper; flipud(lower)], [0.20 0.20 0.20], ... - 'FaceAlpha', 0.15, 'EdgeColor', 'none'); - title(ax, 'Template + Residual Band'); - end - plot(ax, t, template, 'k-', 'LineWidth', 2); - xline(ax, 0, '--r', 'R'); - if strcmp(ddTemplateView.Value, 'Template + residual band') - shadeMeasurementWindows(ax); - end - hold(ax, 'off'); - grid(ax, 'on'); - end - - function shadeMeasurementWindows(ax) - if isempty(S.measurements) || ~isfield(S.measurements, 'metadata') - return; - end - meta = S.measurements.metadata; - if ~isfield(meta, 'signalWindowSec') || ~isfield(meta, 'noiseWindowsSec') - return; - end - yl = ax.YLim; - windowHandles = gobjects(0); - windowHandles(end+1) = drawWindow(ax, meta.signalWindowSec, yl, [1.00 0.20 0.20], 0.08); - noiseWindows = meta.noiseWindowsSec; - for k = 1:size(noiseWindows, 1) - windowHandles(end+1) = drawWindow(ax, noiseWindows(k, :), yl, [0.00 0.45 1.00], 0.08); - end - try - uistack(windowHandles, 'bottom'); - catch - end - end - - function T = analysisTable() - T = S.measurements.perSegment; - smoothBeats = max(1, round(edtSmooth.Value)); - T.SignalP2P_smooth = movingMedian(T.SignalP2P, smoothBeats); - T.NoiseRMS_smooth = movingMedian(T.NoiseRMS, smoothBeats); - T.SNRdB_smooth = movingMedian(T.SNRdB, smoothBeats); - end - - function rows = buildSummaryRows() - rows = initialSummaryRows(); - if ~isempty(S.signal) - rows = [rows; { - 'Channel', char(S.signal.displayName); - 'Samples', sprintf('%d', numel(S.signal.values)); - 'Estimated Fs (Hz)', sprintf('%.3g', S.signal.fs); - 'Duration (s)', sprintf('%.3g', max(S.signal.time) - min(S.signal.time))}]; - end - if ~isempty(S.events) - methodLabel = ''; - if isfield(S.events, 'metadata') && isfield(S.events.metadata, 'method') - methodLabel = sprintf(' (%s)', char(S.events.metadata.method)); - end - rows = [rows; {'Detected peaks', sprintf('%d%s', numel(S.events.index), methodLabel)}]; - end - if ~isempty(S.segments) - rows = [rows; {'Valid segments', sprintf('%d', size(S.segments.values, 2))}]; - end - if ~isempty(S.measurements) && ~isempty(S.measurements.summary) - M = S.measurements.summary; - rows = [rows; { - 'Mean SNR (dB)', sprintf('%.3g', M.SNRdBMean); - 'SNR std (dB)', sprintf('%.3g', M.SNRdBStd); - 'Mean template corr.', sprintf('%.3g', M.TemplateCorrelationMean)}]; - end - end - - function y = movingMedian(x, width) - x = double(x(:)); - width = max(1, round(width)); - y = nan(size(x)); - for i = 1:numel(x) - i1 = max(1, i - floor((width - 1) / 2)); - i2 = min(numel(x), i + ceil((width - 1) / 2)); - y(i) = median(x(i1:i2), 'omitnan'); - end - end - - function value = parseColumnSpec(textValue) - textValue = strtrim(string(textValue)); - numericValue = str2double(textValue); - if isfinite(numericValue) && numericValue == floor(numericValue) - value = numericValue; - else - value = char(textValue); - end - end - - function values = parseColumnList(textValue) - parts = split(string(textValue), {',', ';'}); - parts = strtrim(parts); - parts = parts(strlength(parts) > 0); - numericValues = str2double(parts); - if all(isfinite(numericValues)) && all(numericValues == floor(numericValues)) - values = numericValues(:).'; - else - values = cellstr(parts); - end - end - - function method = peakMethodValue(label) - switch string(label) - case "Pan-Tompkins" - method = "pan-tompkins"; - case "Local peaks" - method = "local"; - otherwise - method = "qrs-streaming"; - end - end - - function h = drawWindow(ax, windowSec, yl, color, alpha) - h = fill(ax, [windowSec(1) windowSec(2) windowSec(2) windowSec(1)], ... - [yl(1) yl(1) yl(2) yl(2)], color, ... - 'FaceAlpha', alpha, 'EdgeColor', 'none', ... - 'HitTest', 'off', 'PickableParts', 'none'); - end - - function resetAxes() - labkit.ui.view.draw(ui.waveAxes, 'reset', 'Waveform + Peaks'); - xlabel(ui.waveAxes, 'Time (s)'); - ylabel(ui.waveAxes, 'Amplitude'); - labkit.ui.view.draw(ui.noiseAxes, 'reset', 'Template Noise RMS Over Time'); - xlabel(ui.noiseAxes, 'Time (s)'); - ylabel(ui.noiseAxes, 'Noise RMS'); - labkit.ui.view.draw(ui.snrAxes, 'reset', 'Template SNR Over Time'); - xlabel(ui.snrAxes, 'Time (s)'); - ylabel(ui.snrAxes, 'SNR (dB)'); - labkit.ui.view.draw(ui.templateAxes, 'reset', 'Template + Residual Band'); - xlabel(ui.templateAxes, 'Time from peak (s)'); - ylabel(ui.templateAxes, 'Amplitude'); - end - - function addLog(message) - labkit.ui.view.update(txtLog, 'appendLog', message); - debugLog.append(message); - end - - function showError(titleText, message) - uialert(fig, char(message), titleText); - addLog(sprintf('%s: %s', titleText, message)); - end -end - -function rows = initialSummaryRows() - rows = {'Status', 'No signal analyzed'}; -end - -function text = importStatusText(recording, channelCount) - meta = recording.metadata; - pieces = strings(1, 0); - pieces(end+1) = sprintf('%d channel(s)', channelCount); - if isfield(meta, 'timeColumn') && strlength(string(meta.timeColumn)) > 0 - pieces(end+1) = "time: " + string(meta.timeColumn); - end - if isfield(meta, 'timeUnit') - pieces(end+1) = "unit: " + string(meta.timeUnit); - end - if isfield(meta, 'timeSource') - pieces(end+1) = "source: " + string(meta.timeSource); - end - if isfield(meta, 'timeRepair') - repair = meta.timeRepair; - if isfield(repair, 'repairedBackwardCount') && repair.repairedBackwardCount > 0 - pieces(end+1) = sprintf('repaired backward: %d', repair.repairedBackwardCount); - end - if isfield(repair, 'largeGapCount') && repair.largeGapCount > 0 - pieces(end+1) = sprintf('large gaps: %d', repair.largeGapCount); - end - end - text = char(strjoin(pieces, ' | ')); -end - -function lines = previewFileHeader(filepath, maxLines) - lines = {}; - fid = fopen(filepath, 'r'); - if fid < 0 - lines = {'Could not open file preview.'}; - return; - end - cleaner = onCleanup(@() fclose(fid)); - for k = 1:maxLines - line = fgetl(fid); - if ~ischar(line) - break; - end - lines{end+1, 1} = sprintf('%02d: %s', k, line); %#ok - end - if isempty(lines) - lines = {'File is empty or could not be previewed.'}; - end end diff --git a/apps/wearable/private/runECGPrintApp.m b/apps/wearable/private/runECGPrintApp.m new file mode 100644 index 0000000..6efb539 --- /dev/null +++ b/apps/wearable/private/runECGPrintApp.m @@ -0,0 +1,767 @@ +% App-owned runner extracted from labkit_ECGPrint_app.m. Expected caller: labkit_ECGPrint_app. +% Input is the debug context prepared by the public launcher. Output is the app +% figure. Side effects are GUI creation, user-driven file I/O, exports, +% plotting, and debug trace attachment exactly as in the original entrypoint body. +function fig = runECGPrintApp(debugLog) +%RUNECGPRINTAPP Build and run the app body. + + S = struct(); + S.recording = []; + S.signal = []; + S.workingSignal = []; + S.filteredSignal = []; + S.events = []; + S.segments = []; + S.template = []; + S.measurements = []; + S.filepath = ""; + + opts = struct( ... + 'rightTitle', 'ECG Preview', ... + 'rightGridSize', [4 1], ... + 'rightRowHeight', {{'1.2x', '1x', '1x', '1x'}}, ... + 'rightRowSpacing', 8); + opts.tabs = [ ... + labkit.ui.app.tab('filesAnalysis', 'Files + Analysis', [6 1], ... + {140, 255, 120, 235, 100, 125}, ... + struct('resizeRows', [1 2 3 4 5])), ... + labkit.ui.app.tab('summaryResults', 'Summary + Results', [2 1], ... + {210, '1x'}, ... + struct('resizeRows', 1)), ... + labkit.ui.app.tab('log', 'Log', [1 1], {'1x'})]; + + ui = labkit.ui.app.createShell(struct( ... + 'title', 'ECG Signal Print + SNR Explorer', ... + 'position', [80 70 1480 880], ... + 'leftWidth', 410, ... + 'options', opts)); + fig = ui.fig; + layFA = ui.filesAnalysisGrid; + laySR = ui.summaryResultsGrid; + layLog = ui.logGrid; + + recordingPanel = labkit.ui.view.section(layFA, 'Recording', 1, [3 2], ... + struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... + 'columnWidth', {{135, '1x'}})); + recordingGrid = recordingPanel.grid; + + btnOpen = uibutton(recordingGrid, 'Text', 'Open recording', 'ButtonPushedFcn', @onOpenRecording); + btnOpen.Layout.Row = 1; + btnOpen.Layout.Column = [1 2]; + + txtFile = labkit.ui.view.form(recordingGrid, 'readonly', 'Value', 'No file loaded'); + txtFile.Layout.Row = 2; + txtFile.Layout.Column = [1 2]; + + btnPreviewHeader = uibutton(recordingGrid, 'Text', 'Preview file header', ... + 'ButtonPushedFcn', @onPreviewHeader); + btnPreviewHeader.Layout.Row = 3; + btnPreviewHeader.Layout.Column = [1 2]; + + importPanel = labkit.ui.view.section(layFA, 'Import Parsing', 2, [8 2], ... + struct('rowHeight', {repmat({'fit'}, 1, 8)}, ... + 'columnWidth', {{135, '1x'}})); + importGrid = importPanel.grid; + + txtImportStatus = labkit.ui.view.form(importGrid, 'readonly', ... + 'Value', 'Open a recording to inspect import settings.'); + txtImportStatus.Layout.Row = 1; + txtImportStatus.Layout.Column = [1 2]; + + [lblHeaderLine, edtHeaderLine] = labkit.ui.view.form(importGrid, 'spinner', ... + 'CSV header line:', 'Value', 0, 'Limits', [0 Inf], 'Step', 1, ... + 'ValueChangedFcn', @onImportOptionChanged); + lblHeaderLine.Layout.Row = 2; + lblHeaderLine.Layout.Column = 1; + edtHeaderLine.Layout.Row = 2; + edtHeaderLine.Layout.Column = 2; + + [lblHasHeader, ddHasHeader] = labkit.ui.view.form(importGrid, 'dropdown', ... + 'CSV header:', ... + 'Items', {'Auto', 'Yes', 'No'}, ... + 'Value', 'Auto', ... + 'ValueChangedFcn', @onImportOptionChanged); + lblHasHeader.Layout.Row = 3; + lblHasHeader.Layout.Column = 1; + ddHasHeader.Layout.Row = 3; + ddHasHeader.Layout.Column = 2; + + [lblTimeColumn, edtTimeColumn] = labkit.ui.view.form(importGrid, 'edit', ... + 'Time column:', 'text', 'Value', '', ... + 'ValueChangedFcn', @onImportOptionChanged); + lblTimeColumn.Layout.Row = 4; + lblTimeColumn.Layout.Column = 1; + edtTimeColumn.Layout.Row = 4; + edtTimeColumn.Layout.Column = 2; + + [lblTimeUnit, ddTimeUnit] = labkit.ui.view.form(importGrid, 'dropdown', ... + 'Time unit:', ... + 'Items', {'Auto', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds'}, ... + 'Value', 'Auto', ... + 'ValueChangedFcn', @onImportOptionChanged); + lblTimeUnit.Layout.Row = 5; + lblTimeUnit.Layout.Column = 1; + ddTimeUnit.Layout.Row = 5; + ddTimeUnit.Layout.Column = 2; + + [lblSignalColumns, edtSignalColumns] = labkit.ui.view.form(importGrid, 'edit', ... + 'Signal columns:', 'text', 'Value', '', ... + 'ValueChangedFcn', @onImportOptionChanged); + lblSignalColumns.Layout.Row = 6; + lblSignalColumns.Layout.Column = 1; + edtSignalColumns.Layout.Row = 6; + edtSignalColumns.Layout.Column = 2; + + [lblFallbackFs, edtFallbackFs] = labkit.ui.view.form(importGrid, 'spinner', ... + 'Fallback Fs:', 'Value', 2000, 'Limits', [0 Inf], 'Step', 100, ... + 'ValueChangedFcn', @onImportOptionChanged); + lblFallbackFs.Layout.Row = 7; + lblFallbackFs.Layout.Column = 1; + edtFallbackFs.Layout.Row = 7; + edtFallbackFs.Layout.Column = 2; + + btnRefreshImport = uibutton(importGrid, 'Text', 'Parse / refresh file', ... + 'ButtonPushedFcn', @onRefreshImport); + btnRefreshImport.Layout.Row = 8; + btnRefreshImport.Layout.Column = [1 2]; + + channelPanel = labkit.ui.view.section(layFA, 'Channel + ROI', 3, [3 2], ... + struct('rowHeight', {repmat({'fit'}, 1, 3)}, ... + 'columnWidth', {{135, '1x'}})); + channelGrid = channelPanel.grid; + + [lblChannel, ddChannel] = labkit.ui.view.form(channelGrid, 'dropdown', 'Channel:', ... + 'Items', {'(none)'}, 'Value', '(none)', 'ValueChangedFcn', @onChannelChanged); + lblChannel.Layout.Row = 1; + lblChannel.Layout.Column = 1; + ddChannel.Layout.Row = 1; + ddChannel.Layout.Column = 2; + + [lblStart, edtStart] = labkit.ui.view.form(channelGrid, 'spinner', ... + 'ROI start (s):', 'Value', 0, 'Limits', [0 Inf], 'Step', 1); + lblStart.Layout.Row = 2; + lblStart.Layout.Column = 1; + edtStart.Layout.Row = 2; + edtStart.Layout.Column = 2; + + [lblEnd, edtEnd] = labkit.ui.view.form(channelGrid, 'spinner', ... + 'ROI end (s):', 'Value', 0, 'Limits', [0 Inf], 'Step', 1); + lblEnd.Layout.Row = 3; + lblEnd.Layout.Column = 1; + edtEnd.Layout.Row = 3; + edtEnd.Layout.Column = 2; + + procPanel = labkit.ui.view.section(layFA, 'Signal Processing + SNR', 4, [9 2], ... + struct('rowHeight', {repmat({'fit'}, 1, 9)}, ... + 'columnWidth', {{135, '1x'}})); + procGrid = procPanel.grid; + + [lblLow, edtLow] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Bandpass low Hz:', 'Value', 0.5, 'Limits', [0 Inf], 'Step', 0.1); + lblLow.Layout.Row = 1; + lblLow.Layout.Column = 1; + edtLow.Layout.Row = 1; + edtLow.Layout.Column = 2; + + [lblHigh, edtHigh] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Bandpass high Hz:', 'Value', 40, 'Limits', [0 Inf], 'Step', 1); + lblHigh.Layout.Row = 2; + lblHigh.Layout.Column = 1; + edtHigh.Layout.Row = 2; + edtHigh.Layout.Column = 2; + + [lblPeakMethod, ddPeakMethod] = labkit.ui.view.form(procGrid, 'dropdown', ... + 'Peak method:', ... + 'Items', {'QRS streaming', 'Pan-Tompkins', 'Local peaks'}, ... + 'Value', 'QRS streaming'); + lblPeakMethod.Layout.Row = 3; + lblPeakMethod.Layout.Column = 1; + ddPeakMethod.Layout.Row = 3; + ddPeakMethod.Layout.Column = 2; + + [lblPeakDist, edtPeakDist] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Peak distance (s):', 'Value', 0.28, 'Limits', [0.01 Inf], 'Step', 0.01); + lblPeakDist.Layout.Row = 4; + lblPeakDist.Layout.Column = 1; + edtPeakDist.Layout.Row = 4; + edtPeakDist.Layout.Column = 2; + + [lblWin, edtWin] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Segment half win (s):', 'Value', 0.7, 'Limits', [0.01 Inf], 'Step', 0.05); + lblWin.Layout.Row = 5; + lblWin.Layout.Column = 1; + edtWin.Layout.Row = 5; + edtWin.Layout.Column = 2; + + [lblTopN, edtTopN] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Template top N:', 'Value', 30, 'Limits', [1 Inf], 'Step', 1); + lblTopN.Layout.Row = 6; + lblTopN.Layout.Column = 1; + edtTopN.Layout.Row = 6; + edtTopN.Layout.Column = 2; + + [lblSmooth, edtSmooth] = labkit.ui.view.form(procGrid, 'spinner', ... + 'Smooth beats:', 'Value', 15, 'Limits', [1 Inf], 'Step', 1, ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + lblSmooth.Layout.Row = 7; + lblSmooth.Layout.Column = 1; + edtSmooth.Layout.Row = 7; + edtSmooth.Layout.Column = 2; + + [lblView, ddTemplateView] = labkit.ui.view.form(procGrid, 'dropdown', ... + 'Template plot:', ... + 'Items', {'Template + residual band', 'Template + segments'}, ... + 'Value', 'Template + residual band', ... + 'ValueChangedFcn', @(~,~) refreshPlots()); + lblView.Layout.Row = 8; + lblView.Layout.Column = 1; + ddTemplateView.Layout.Row = 8; + ddTemplateView.Layout.Column = 2; + + btnAnalyze = uibutton(procGrid, 'Text', 'Analyze current ROI', ... + 'ButtonPushedFcn', @onAnalyze); + btnAnalyze.Layout.Row = 9; + btnAnalyze.Layout.Column = [1 2]; + + exportPanel = labkit.ui.view.section(layFA, 'Exports', 5, [2 1], ... + struct('rowHeight', {{'fit','fit'}})); + exportGrid = exportPanel.grid; + btnExportSegments = uibutton(exportGrid, 'Text', 'Export segment SNR CSV', ... + 'ButtonPushedFcn', @onExportSegments); + btnExportSegments.Layout.Row = 1; + btnExportOverlay = uibutton(exportGrid, 'Text', 'Export waveform PNG', ... + 'ButtonPushedFcn', @onExportWaveform); + btnExportOverlay.Layout.Row = 2; + + labkit.ui.view.panel(layFA, 'text', 'Workflow Notes', 6, { ... + '1. Open MAT/CSV data, select a numeric channel, and optionally set a time ROI.', ... + '2. Use File Header Preview and Import Parsing only when CSV/text auto-detection needs correction.', ... + '3. Analysis filters the selected channel with edge padding, then crops the filtered signal to the ROI for peak/SNR measurement.'}); + + summaryTable = uitable(laySR, 'ColumnName', {'Metric','Value'}, ... + 'Data', initialSummaryRows()); + labkit.ui.view.place(summaryTable, laySR, 1); + + previewUi = labkit.ui.view.panel(laySR, 'text', 'File Header Preview', 2, ... + {'Open a CSV/text file, then use Preview file header.'}); + txtFilePreview = previewUi.textArea; + + logUi = labkit.ui.view.panel(layLog, 'log', 1, {'Ready.'}); + txtLog = logUi.textArea; + + ui.waveAxes = uiaxes(ui.rightGrid); + ui.waveAxes.Layout.Row = 1; + ui.noiseAxes = uiaxes(ui.rightGrid); + ui.noiseAxes.Layout.Row = 2; + ui.snrAxes = uiaxes(ui.rightGrid); + ui.snrAxes.Layout.Row = 3; + ui.templateAxes = uiaxes(ui.rightGrid); + ui.templateAxes.Layout.Row = 4; + + if debugLog.enabled + debugLog.attachTextLog(txtLog); + debugLog.trace('ECG print debug trace enabled.'); + debugLog.instrumentFigure(fig); + end + + resetAxes(); + + function onOpenRecording(~, ~) + [fn, fp] = uigetfile( ... + {'*.mat;*.csv;*.txt;*.tsv', 'Biosignal files (*.mat, *.csv, *.txt, *.tsv)'; ... + '*.*', 'All files'}, ... + 'Select biosignal recording'); + if isequal(fn, 0) + addLog('Recording selection cancelled.'); + return; + end + + S.filepath = string(fullfile(fp, fn)); + txtFile.Value = char(S.filepath); + clearParsedRecording(); + updateFilePreview(); + refreshImportParsing(false); + end + + function onRefreshImport(~, ~) + refreshImportParsing(true); + end + + function refreshImportParsing(showAlertOnFailure) + if nargin < 1 + showAlertOnFailure = true; + end + if strlength(S.filepath) == 0 + if showAlertOnFailure + showError('No recording selected', 'Open a recording before parsing.'); + else + txtImportStatus.Value = 'Open a recording before parsing.'; + end + return; + end + + txtImportStatus.Value = 'Parsing file...'; + selectedChannel = ""; + if ~isempty(ddChannel.Items) && ~strcmp(ddChannel.Value, '(none)') + selectedChannel = string(ddChannel.Value); + end + + importOpts = currentImportOptions(); + [recording, status] = labkit.biosignal.readRecording(char(S.filepath), importOpts); + if ~status.ok + clearParsedRecording(); + txtImportStatus.Value = char("Parse failed. Inspect header/settings, then refresh: " + status.message); + if showAlertOnFailure + showError('Could not parse recording', status.message); + else + addLog(sprintf('Automatic parse failed: %s', status.message)); + end + return; + end + + S.recording = recording; + channels = labkit.biosignal.listChannels(recording); + if isempty(channels) + clearParsedRecording(); + txtImportStatus.Value = 'Parse failed: no numeric signal channels were found.'; + if showAlertOnFailure + showError('Could not parse recording', 'No numeric signal channels were found.'); + end + return; + end + ddChannel.Items = channels; + if any(strcmp(channels, selectedChannel)) + ddChannel.Value = char(selectedChannel); + else + ddChannel.Value = channels{1}; + end + setCurrentChannel(ddChannel.Value); + txtImportStatus.Value = importStatusText(recording, numel(channels)); + addLog(sprintf('Parsed %d channel(s) from %s', numel(channels), char(S.filepath))); + end + + function onPreviewHeader(~, ~) + updateFilePreview(); + end + + function updateFilePreview() + if strlength(S.filepath) == 0 + txtFilePreview.Value = {'Open a CSV/text file, then use Preview file header.'}; + return; + end + txtFilePreview.Value = previewFileHeader(char(S.filepath), 18); + addLog(sprintf('Previewed file header: %s', char(S.filepath))); + end + + function onImportOptionChanged(~, ~) + if strlength(S.filepath) > 0 + txtImportStatus.Value = 'Import settings changed. Click Parse / refresh file.'; + end + end + + function clearParsedRecording() + S.recording = []; + S.signal = []; + S.workingSignal = []; + S.filteredSignal = []; + S.events = []; + S.segments = []; + S.template = []; + S.measurements = []; + ddChannel.Items = {'(none)'}; + ddChannel.Value = '(none)'; + edtStart.Value = 0; + edtEnd.Value = 0; + updateSummary(); + refreshPlots(); + end + + function optsOut = currentImportOptions() + optsOut = struct('fallbackFs', edtFallbackFs.Value); + if edtHeaderLine.Value > 0 + optsOut.headerLine = round(edtHeaderLine.Value); + end + switch string(ddHasHeader.Value) + case "Yes" + optsOut.hasHeader = true; + case "No" + optsOut.hasHeader = false; + end + if strlength(strtrim(string(edtTimeColumn.Value))) > 0 + optsOut.timeColumn = parseColumnSpec(edtTimeColumn.Value); + end + if string(ddTimeUnit.Value) ~= "Auto" + optsOut.timeUnit = ddTimeUnit.Value; + end + if strlength(strtrim(string(edtSignalColumns.Value))) > 0 + optsOut.signalColumns = parseColumnList(edtSignalColumns.Value); + end + end + + function onChannelChanged(~, ~) + if isempty(S.recording) || strcmp(ddChannel.Value, '(none)') + return; + end + setCurrentChannel(ddChannel.Value); + end + + function setCurrentChannel(channelName) + S.signal = labkit.biosignal.getChannel(S.recording, channelName); + S.workingSignal = S.signal; + S.filteredSignal = []; + S.events = []; + S.segments = []; + S.template = []; + S.measurements = []; + if ~isempty(S.signal.time) + edtStart.Value = 0; + edtEnd.Value = max(S.signal.time); + end + updateSummary(); + refreshPlots(); + end + + function onAnalyze(~, ~) + if isempty(S.signal) + showError('No channel selected', 'Open a recording and select a channel first.'); + return; + end + + try + timeRange = [edtStart.Value edtEnd.Value]; + highCut = min(edtHigh.Value, max(edtLow.Value + eps, 0.45 * S.signal.fs)); + filterSpec = struct('type', 'bandpass', 'cutoffHz', [edtLow.Value highCut]); + fullFiltered = labkit.biosignal.filterSignal(S.signal, filterSpec); + if timeRange(2) > timeRange(1) + S.workingSignal = labkit.biosignal.cropSignal(S.signal, timeRange); + S.filteredSignal = labkit.biosignal.cropSignal(fullFiltered, timeRange); + else + S.workingSignal = S.signal; + S.filteredSignal = fullFiltered; + end + peakOpts = struct('polarity', 'auto', ... + 'method', peakMethodValue(ddPeakMethod.Value), ... + 'minDistanceSec', edtPeakDist.Value, ... + 'thresholdStd', 2.8); + S.events = labkit.biosignal.detectEcgPeaks(S.filteredSignal, peakOpts); + halfWin = edtWin.Value; + S.segments = labkit.biosignal.segmentByEvents(S.filteredSignal, S.events, [-halfWin halfWin]); + S.template = labkit.biosignal.buildTemplate(S.segments, struct('topN', edtTopN.Value)); + S.measurements = labkit.biosignal.measureSegments(S.segments, S.template); + + addLog(sprintf('Filtered channel, then analyzed ROI with %s: %d peaks, %d valid segments.', ... + ddPeakMethod.Value, numel(S.events.index), size(S.segments.values, 2))); + updateSummary(); + refreshPlots(); + catch ME + showError('Analysis failed', ME.message); + end + end + + function onExportSegments(~, ~) + if isempty(S.measurements) || isempty(S.measurements.perSegment) + showError('No segment SNR', 'Analyze a signal before exporting segment SNR.'); + return; + end + [fn, fp] = uiputfile('ecg_segment_snr.csv', 'Export segment SNR CSV'); + if isequal(fn, 0) + addLog('Segment SNR export cancelled.'); + return; + end + writetable(analysisTable(), fullfile(fp, fn)); + addLog(sprintf('Exported segment SNR CSV: %s', fullfile(fp, fn))); + end + + function onExportWaveform(~, ~) + [fn, fp] = uiputfile('ecg_waveform.png', 'Export waveform PNG'); + if isequal(fn, 0) + addLog('Waveform export cancelled.'); + return; + end + exportgraphics(ui.waveAxes, fullfile(fp, fn), 'Resolution', 300); + addLog(sprintf('Exported waveform PNG: %s', fullfile(fp, fn))); + end + + function refreshPlots() + resetAxes(); + if isempty(S.workingSignal) + return; + end + + sig = S.workingSignal; + if ~isempty(S.filteredSignal) + sig = S.filteredSignal; + end + + ax = ui.waveAxes; + plot(ax, sig.time, sig.values, 'Color', [0.15 0.38 0.72], 'LineWidth', 1); + hold(ax, 'on'); + if ~isempty(S.events) && ~isempty(S.events.index) + scatter(ax, sig.time(S.events.index), sig.values(S.events.index), ... + 24, [0.85 0.25 0.15], 'filled'); + end + hold(ax, 'off'); + title(ax, 'Waveform + Peaks'); + xlabel(ax, 'Time (s)'); + ylabel(ax, char(sig.name)); + grid(ax, 'on'); + + if isempty(S.measurements) + return; + end + + T = analysisTable(); + smoothBeats = max(1, round(edtSmooth.Value)); + + noiseAx = ui.noiseAxes; + plot(noiseAx, T.EventTime, T.NoiseRMS, '.', 'MarkerSize', 12, ... + 'Color', [0.20 0.45 0.72]); + hold(noiseAx, 'on'); + plot(noiseAx, T.EventTime, movingMedian(T.NoiseRMS, smoothBeats), '-', ... + 'LineWidth', 1.5, 'Color', [0.05 0.20 0.45]); + hold(noiseAx, 'off'); + title(noiseAx, sprintf('Template Noise RMS Over Time | Smooth=%d beats', smoothBeats)); + xlabel(noiseAx, 'Time (s)'); + ylabel(noiseAx, 'Noise RMS'); + grid(noiseAx, 'on'); + + snrAx = ui.snrAxes; + plot(snrAx, T.EventTime, T.SNRdB, '.', 'MarkerSize', 12, ... + 'Color', [0.18 0.55 0.32]); + hold(snrAx, 'on'); + plot(snrAx, T.EventTime, movingMedian(T.SNRdB, smoothBeats), '-', ... + 'LineWidth', 1.5, 'Color', [0.05 0.32 0.16]); + hold(snrAx, 'off'); + title(snrAx, sprintf('Template SNR Over Time | Smooth=%d beats', smoothBeats)); + xlabel(snrAx, 'Time (s)'); + ylabel(snrAx, 'SNR (dB)'); + grid(snrAx, 'on'); + + refreshTemplatePlot(); + end + + function updateSummary() + summaryTable.Data = buildSummaryRows(); + end + + function refreshTemplatePlot() + ax = ui.templateAxes; + labkit.ui.view.draw(ax, 'reset', 'Template + Residual Band'); + xlabel(ax, 'Time from peak (s)'); + ylabel(ax, 'Amplitude'); + if isempty(S.segments) || isempty(S.template) || isempty(S.segments.values) + return; + end + + X = double(S.segments.values); + t = double(S.segments.timeOffset(:)); + template = double(S.template.values(:)); + if isempty(X) || isempty(template) + return; + end + + hold(ax, 'on'); + if strcmp(ddTemplateView.Value, 'Template + segments') + maxShow = min(40, size(X, 2)); + showIdx = unique(round(linspace(1, size(X, 2), maxShow))); + plot(ax, t, X(:, showIdx), 'Color', [0.78 0.84 0.92], 'LineWidth', 0.5); + title(ax, 'Template + Segments'); + else + residStd = std(X - template, 0, 2, 'omitnan'); + upper = template + residStd; + lower = template - residStd; + fill(ax, [t; flipud(t)], [upper; flipud(lower)], [0.20 0.20 0.20], ... + 'FaceAlpha', 0.15, 'EdgeColor', 'none'); + title(ax, 'Template + Residual Band'); + end + plot(ax, t, template, 'k-', 'LineWidth', 2); + xline(ax, 0, '--r', 'R'); + if strcmp(ddTemplateView.Value, 'Template + residual band') + shadeMeasurementWindows(ax); + end + hold(ax, 'off'); + grid(ax, 'on'); + end + + function shadeMeasurementWindows(ax) + if isempty(S.measurements) || ~isfield(S.measurements, 'metadata') + return; + end + meta = S.measurements.metadata; + if ~isfield(meta, 'signalWindowSec') || ~isfield(meta, 'noiseWindowsSec') + return; + end + yl = ax.YLim; + windowHandles = gobjects(0); + windowHandles(end+1) = drawWindow(ax, meta.signalWindowSec, yl, [1.00 0.20 0.20], 0.08); + noiseWindows = meta.noiseWindowsSec; + for k = 1:size(noiseWindows, 1) + windowHandles(end+1) = drawWindow(ax, noiseWindows(k, :), yl, [0.00 0.45 1.00], 0.08); + end + try + uistack(windowHandles, 'bottom'); + catch + end + end + + function T = analysisTable() + T = S.measurements.perSegment; + smoothBeats = max(1, round(edtSmooth.Value)); + T.SignalP2P_smooth = movingMedian(T.SignalP2P, smoothBeats); + T.NoiseRMS_smooth = movingMedian(T.NoiseRMS, smoothBeats); + T.SNRdB_smooth = movingMedian(T.SNRdB, smoothBeats); + end + + function rows = buildSummaryRows() + rows = initialSummaryRows(); + if ~isempty(S.signal) + rows = [rows; { + 'Channel', char(S.signal.displayName); + 'Samples', sprintf('%d', numel(S.signal.values)); + 'Estimated Fs (Hz)', sprintf('%.3g', S.signal.fs); + 'Duration (s)', sprintf('%.3g', max(S.signal.time) - min(S.signal.time))}]; + end + if ~isempty(S.events) + methodLabel = ''; + if isfield(S.events, 'metadata') && isfield(S.events.metadata, 'method') + methodLabel = sprintf(' (%s)', char(S.events.metadata.method)); + end + rows = [rows; {'Detected peaks', sprintf('%d%s', numel(S.events.index), methodLabel)}]; + end + if ~isempty(S.segments) + rows = [rows; {'Valid segments', sprintf('%d', size(S.segments.values, 2))}]; + end + if ~isempty(S.measurements) && ~isempty(S.measurements.summary) + M = S.measurements.summary; + rows = [rows; { + 'Mean SNR (dB)', sprintf('%.3g', M.SNRdBMean); + 'SNR std (dB)', sprintf('%.3g', M.SNRdBStd); + 'Mean template corr.', sprintf('%.3g', M.TemplateCorrelationMean)}]; + end + end + + function y = movingMedian(x, width) + x = double(x(:)); + width = max(1, round(width)); + y = nan(size(x)); + for i = 1:numel(x) + i1 = max(1, i - floor((width - 1) / 2)); + i2 = min(numel(x), i + ceil((width - 1) / 2)); + y(i) = median(x(i1:i2), 'omitnan'); + end + end + + function value = parseColumnSpec(textValue) + textValue = strtrim(string(textValue)); + numericValue = str2double(textValue); + if isfinite(numericValue) && numericValue == floor(numericValue) + value = numericValue; + else + value = char(textValue); + end + end + + function values = parseColumnList(textValue) + parts = split(string(textValue), {',', ';'}); + parts = strtrim(parts); + parts = parts(strlength(parts) > 0); + numericValues = str2double(parts); + if all(isfinite(numericValues)) && all(numericValues == floor(numericValues)) + values = numericValues(:).'; + else + values = cellstr(parts); + end + end + + function method = peakMethodValue(label) + switch string(label) + case "Pan-Tompkins" + method = "pan-tompkins"; + case "Local peaks" + method = "local"; + otherwise + method = "qrs-streaming"; + end + end + + function h = drawWindow(ax, windowSec, yl, color, alpha) + h = fill(ax, [windowSec(1) windowSec(2) windowSec(2) windowSec(1)], ... + [yl(1) yl(1) yl(2) yl(2)], color, ... + 'FaceAlpha', alpha, 'EdgeColor', 'none', ... + 'HitTest', 'off', 'PickableParts', 'none'); + end + + function resetAxes() + labkit.ui.view.draw(ui.waveAxes, 'reset', 'Waveform + Peaks'); + xlabel(ui.waveAxes, 'Time (s)'); + ylabel(ui.waveAxes, 'Amplitude'); + labkit.ui.view.draw(ui.noiseAxes, 'reset', 'Template Noise RMS Over Time'); + xlabel(ui.noiseAxes, 'Time (s)'); + ylabel(ui.noiseAxes, 'Noise RMS'); + labkit.ui.view.draw(ui.snrAxes, 'reset', 'Template SNR Over Time'); + xlabel(ui.snrAxes, 'Time (s)'); + ylabel(ui.snrAxes, 'SNR (dB)'); + labkit.ui.view.draw(ui.templateAxes, 'reset', 'Template + Residual Band'); + xlabel(ui.templateAxes, 'Time from peak (s)'); + ylabel(ui.templateAxes, 'Amplitude'); + end + + function addLog(message) + labkit.ui.view.update(txtLog, 'appendLog', message); + debugLog.append(message); + end + + function showError(titleText, message) + uialert(fig, char(message), titleText); + addLog(sprintf('%s: %s', titleText, message)); + end +end + +function rows = initialSummaryRows() + rows = {'Status', 'No signal analyzed'}; +end + +function text = importStatusText(recording, channelCount) + meta = recording.metadata; + pieces = strings(1, 0); + pieces(end+1) = sprintf('%d channel(s)', channelCount); + if isfield(meta, 'timeColumn') && strlength(string(meta.timeColumn)) > 0 + pieces(end+1) = "time: " + string(meta.timeColumn); + end + if isfield(meta, 'timeUnit') + pieces(end+1) = "unit: " + string(meta.timeUnit); + end + if isfield(meta, 'timeSource') + pieces(end+1) = "source: " + string(meta.timeSource); + end + if isfield(meta, 'timeRepair') + repair = meta.timeRepair; + if isfield(repair, 'repairedBackwardCount') && repair.repairedBackwardCount > 0 + pieces(end+1) = sprintf('repaired backward: %d', repair.repairedBackwardCount); + end + if isfield(repair, 'largeGapCount') && repair.largeGapCount > 0 + pieces(end+1) = sprintf('large gaps: %d', repair.largeGapCount); + end + end + text = char(strjoin(pieces, ' | ')); +end + +function lines = previewFileHeader(filepath, maxLines) + lines = {}; + fid = fopen(filepath, 'r'); + if fid < 0 + lines = {'Could not open file preview.'}; + return; + end + cleaner = onCleanup(@() fclose(fid)); + for k = 1:maxLines + line = fgetl(fid); + if ~ischar(line) + break; + end + lines{end+1, 1} = sprintf('%02d: %s', k, line); %#ok + end + if isempty(lines) + lines = {'File is empty or could not be previewed.'}; + end +end diff --git a/buildfile.m b/buildfile.m new file mode 100644 index 0000000..456521e --- /dev/null +++ b/buildfile.m @@ -0,0 +1,286 @@ +function plan = buildfile +%BUILDFILE LabKit build and validation entry points. + + plan = buildplan(localfunctions); + plan.DefaultTasks = "test"; + + plan("checkStyle").Description = "Run project/style guardrails."; + plan("test").Description = "Run the full non-GUI test entry point."; + plan("testUnit").Description = "Run official unit tests."; + plan("testIntegration").Description = "Run official integration tests."; + plan("testGuiStructural").Description = "Run noninteractive GUI structural tests."; + plan("testGuiGesture").Description = "Run noninteractive/manual GUI gesture tests."; + plan("coverage").Description = "Run official tests with coverage artifacts."; + plan("checkProject").Description = "Verify MATLAB Project metadata and path setup."; + plan("packageDryRun").Description = "Verify package boundary inventory without exporting."; +end + +function checkStyleTask(~) + runBuildTests("checkStyle", ... + "Suites", "project", ... + "Tags", "Style", ... + "FailIfNoTests", false); +end + +function testTask(~) + runBuildTests("test", ... + "IncludeGui", false, ... + "FailIfNoTests", false); +end + +function testUnitTask(~) + runBuildTests("testUnit", ... + "Tags", "Unit", ... + "FailIfNoTests", false); +end + +function testIntegrationTask(~) + runBuildTests("testIntegration", ... + "Tags", "Integration", ... + "FailIfNoTests", false); +end + +function testGuiStructuralTask(~) + runBuildTests("testGuiStructural", ... + "Suites", "gui", ... + "Tags", "Structural", ... + "IncludeGui", true, ... + "FailIfNoTests", false); +end + +function testGuiGestureTask(~) + runBuildTests("testGuiGesture", ... + "Tags", "Gesture", ... + "IncludeGui", true, ... + "FailIfNoTests", false); +end + +function coverageTask(~) + runBuildTests("coverage", ... + "Tags", ["Unit", "Integration"], ... + "IncludeCoverage", true, ... + "FailIfNoTests", false); +end + +function checkProjectTask(~) + root = fileparts(mfilename("fullpath")); + checkProjectDefinition(root); +end + +function packageDryRunTask(~) + root = fileparts(mfilename("fullpath")); + checkProjectDefinition(root); + + packageCandidates = [ ... + "+labkit", ... + "apps", ... + "docs", ... + "scripts", ... + "resources/project", ... + "README.md", ... + "LabKit.prj", ... + "buildfile.m", ... + "startup_labkit.m"]; + validationOnly = [ ... + "tests", ... + "AGENTS.md"]; + excludedGeneratedOrLocal = [ ... + "artifacts", ... + "photos", ... + ".git", ... + "LABKIT_REFACTOR_ROADMAP.md"]; + + assertRelativePathsExist(root, packageCandidates); + assertRelativePathsExist(root, validationOnly); + + report = struct( ... + "schemaVersion", 1, ... + "packageCandidates", {cellstr(packageCandidates)}, ... + "validationOnly", {cellstr(validationOnly)}, ... + "excludedGeneratedOrLocal", {cellstr(excludedGeneratedOrLocal)}, ... + "createsToolbox", false); + reportFile = writePackageDryRunReport(root, report); + + fprintf("LabKit package dry run wrote:\n %s\n", reportFile); + fprintf("Package candidates: %d, validation-only roots/files: %d\n", ... + numel(packageCandidates), numel(validationOnly)); +end + +function runBuildTests(runName, varargin) + root = fileparts(mfilename("fullpath")); + addpath(fullfile(root, "tests")); + runLabKitTests(varargin{:}, ... + "RunName", runName, ... + "ArtifactsRoot", fullfile(root, "artifacts")); +end + +function checkProjectDefinition(root) + projectFile = fullfile(root, "LabKit.prj"); + if exist(projectFile, "file") ~= 2 + error("LabKit:Build:MissingProject", ... + "Expected MATLAB Project file is missing: %s", projectFile); + end + + [proj, shouldCloseProject] = openLabKitProject(projectFile, root); + cleanup = onCleanup(@() closeProjectIfLoaded(proj, shouldCloseProject)); + + if string(proj.Name) ~= "LabKit" + error("LabKit:Build:ProjectName", ... + "Expected project name LabKit, found %s.", string(proj.Name)); + end + if normalizePath(proj.RootFolder) ~= normalizePath(root) + error("LabKit:Build:ProjectRoot", ... + "Project root does not match repository root."); + end + + expectedPaths = expectedProjectPaths(root); + actualPaths = normalizePaths(projectEntryPaths(proj.ProjectPath)); + for k = 1:numel(expectedPaths) + if ~any(actualPaths == normalizePath(expectedPaths(k))) + error("LabKit:Build:ProjectPath", ... + "Project path is missing required folder: %s", expectedPaths(k)); + end + end + + assertNoHiddenProjectPath(root, actualPaths); + + startupFiles = normalizePaths(projectEntryPaths(proj.StartupFiles)); + if ~any(startupFiles == normalizePath(fullfile(root, "startup_labkit.m"))) + error("LabKit:Build:ProjectStartup", ... + "Project startup files must include startup_labkit.m."); + end + + fprintf("LabKit MATLAB Project metadata verified.\n"); + clear cleanup +end + +function [proj, shouldCloseProject] = openLabKitProject(projectFile, root) + shouldCloseProject = true; + try + proj = currentProject; + if normalizePath(proj.RootFolder) == normalizePath(root) + shouldCloseProject = false; + return; + end + catch + end + proj = openProject(projectFile); +end + +function paths = expectedProjectPaths(root) + paths = string(root); + appsRoot = fullfile(root, "apps"); + if exist(appsRoot, "dir") == 7 + paths = [paths, string(appsRoot), appPathDirs(appsRoot)]; %#ok + end + paths = unique(paths, "stable"); +end + +function dirs = appPathDirs(appRoot) + dirs = strings(1, 0); + entries = dir(appRoot); + [~, order] = sort({entries.name}); + entries = entries(order); + for k = 1:numel(entries) + entry = entries(k); + if ~entry.isdir || strcmp(entry.name, ".") || strcmp(entry.name, "..") + continue; + end + if startsWith(entry.name, ".") || startsWith(entry.name, "+") || ... + startsWith(entry.name, "@") || strcmp(entry.name, "private") + continue; + end + + child = string(fullfile(entry.folder, entry.name)); + dirs = [dirs, child, appPathDirs(child)]; %#ok + end +end + +function paths = projectEntryPaths(entries) + if isempty(entries) + paths = strings(1, 0); + elseif isstring(entries) || ischar(entries) || iscellstr(entries) + paths = string(entries); + else + paths = strings(1, numel(entries)); + for k = 1:numel(entries) + if isprop(entries(k), "File") + paths(k) = string(entries(k).File); + else + paths(k) = string(entries(k)); + end + end + end +end + +function assertNoHiddenProjectPath(root, actualPaths) + rootPath = normalizePath(root); + for k = 1:numel(actualPaths) + path = actualPaths(k); + if path == rootPath + continue; + end + if startsWith(path, rootPath + "/") + relativePath = extractAfter(path, strlength(rootPath) + 1); + else + relativePath = path; + end + parts = split(relativePath, "/"); + if any(parts == "private" | startsWith(parts, "+") | ... + startsWith(parts, "@") | startsWith(parts, ".")) + error("LabKit:Build:ProjectHiddenPath", ... + "Project path includes private/package/hidden folder: %s", path); + end + end +end + +function assertRelativePathsExist(root, relativePaths) + for k = 1:numel(relativePaths) + path = fullfile(root, relativePaths(k)); + if exist(path, "file") ~= 2 && exist(path, "dir") ~= 7 + error("LabKit:Build:PackageDryRunMissingPath", ... + "Package dry run expected path is missing: %s", relativePaths(k)); + end + end +end + +function reportFile = writePackageDryRunReport(root, report) + reportDir = fullfile(root, "artifacts", "package"); + if exist(reportDir, "dir") ~= 7 + mkdir(reportDir); + end + reportFile = fullfile(reportDir, "package-dry-run.json"); + fid = fopen(reportFile, "w"); + if fid < 0 + error("LabKit:Build:PackageDryRunReport", ... + "Could not write package dry-run report: %s", reportFile); + end + cleanup = onCleanup(@() fclose(fid)); + fwrite(fid, jsonencode(report), "char"); + clear cleanup +end + +function normalized = normalizePaths(paths) + normalized = strings(size(paths)); + for k = 1:numel(paths) + normalized(k) = normalizePath(paths(k)); + end +end + +function normalized = normalizePath(path) + normalized = replace(string(path), "\", "/"); + normalized = regexprep(normalized, "/+$", ""); + normalized = lower(normalized); +end + +function closeProjectIfLoaded(proj, shouldCloseProject) + if ~shouldCloseProject || isempty(proj) || ~isvalid(proj) + return; + end + try + if proj.isLoaded + proj.close; + end + catch + end +end diff --git a/docs/apps.md b/docs/apps.md index 98e834c..b99abbc 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -59,7 +59,7 @@ The app owns: - failed-row behavior - callback ordering, alerts, and log wording -Every public app entry point should preserve its launch name, route internal test/debug requests through `labkit.ui.app.dispatchRequest`, build the GUI with `labkit.ui.app.createShell`, and keep visible debug trace wired into the Log tab during debug launches. Image apps with drawing, scale bars, ROI, or preview scroll should pass a `labkit.ui.tool.createRuntime` result into reusable tools instead of owning figure pointer callbacks directly. +Every public app entry point should preserve its launch name, route debug launch requests through `labkit.ui.app.dispatchRequest`, build the GUI with `labkit.ui.app.createShell`, and keep visible debug trace wired into the Log tab during debug launches. Image apps with drawing, scale bars, ROI, or preview scroll should pass a `labkit.ui.tool.createRuntime` result into reusable tools instead of owning figure pointer callbacks directly. Move code into `+labkit` only when it is reusable without app vocabulary, testable independently, and useful beyond one workflow. When a documented UI tool owns app-neutral interaction mechanics, the app should consume that tool and keep workflow meaning, summaries, and exports app-local. @@ -68,7 +68,7 @@ Move code into `+labkit` only when it is reusable without app vocabulary, testab New lab apps should start as explicit public entry points under `apps//` or `apps///` when the app needs private helpers. A typical single-file order is: ```text -1. Entry validation and optional internal test/debug hook +1. Entry validation and optional debug launch hook 2. App state and GUI construction 3. Nested callbacks for file/session actions 4. Nested refresh/render/export callbacks that touch UI handles @@ -79,9 +79,11 @@ New lab apps should start as explicit public entry points under `apps/ 9. Small formatting, parsing, interpolation, and numeric utilities ``` -Nested functions may read and update GUI handles or app state. Local functions after the app `end` should be GUI-free when practical so focused tests can exercise them through narrow internal app hooks. +Nested functions may read and update GUI handles or app state. Local functions after the app `end` should be GUI-free when practical; extracted app-owned workflow helpers can give focused tests direct access without adding reusable `+labkit` APIs. -The preferred public shape is one launchable app entry point per workflow. If an app becomes too large, app-owned private helpers are acceptable when they stay under the owning app tree and do not become public reusable APIs. Move GUI-free calculations, export builders, deterministic image/signal transforms, and formatting utilities to `apps///private/` when that makes the public app file easier to scan. Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family. Keep GUI state, callbacks, user alerts, workflow ordering, and internal test-command routing in the public app file. +The preferred public shape is one launchable app entry point per workflow. If an app becomes too large, app-owned private helpers are acceptable when they stay under the owning app tree and do not become public reusable APIs. Move GUI-free calculations, export builders, deterministic image/signal transforms, and formatting utilities to `apps///private/` when that makes the public app file easier to scan. Use `apps//private/` only for helpers that are genuinely shared by multiple apps in that family. Keep GUI state, callbacks, user alerts, workflow ordering, and debug launch routing in the public app file. + +For callback-heavy migrated apps, the public launcher may delegate the app body to an app-private runner under the same app tree when that is the smallest behavior-preserving way to keep the launch contract clear. The runner remains app-owned production code; it is not a reusable facade and should not move app-specific workflow decisions into `+labkit`. ## New App Checklist diff --git a/docs/architecture.md b/docs/architecture.md index 7c23995..8d12a70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,7 @@ labkit_FocusStack_app labkit_ECGPrint_app ``` -`startup_labkit` adds the repository root, `apps/`, and normal nested app category folders to the MATLAB path. +`startup_labkit` adds the repository root, `apps/`, and normal nested app category folders to the MATLAB path. `LabKit.prj` records the same path setup and uses `startup_labkit.m` as the project startup file for users who open the repository as a MATLAB Project. ## Package Responsibilities diff --git a/docs/testing.md b/docs/testing.md index afa93ac..3da6c58 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,6 +12,30 @@ Do not claim behavior is preserved unless tests or fixtures support that claim. ## Test Commands +Use the MATLAB build tasks for the common official test entry points: + +```bash +buildtool checkStyle +buildtool test +buildtool testUnit +buildtool testIntegration +buildtool testGuiStructural +buildtool testGuiGesture +buildtool coverage +buildtool checkProject +buildtool packageDryRun +``` + +- `buildtool test` is the full non-GUI entry point. +- `buildtool checkStyle` runs official project/style guardrails. +- `buildtool coverage` generates official JUnit, HTML test result, Cobertura, + and HTML coverage artifacts. Coverage is report-only. +- `buildtool testGuiGesture` runs focused noninteractive gesture coverage for + runtime, anchor editor, and scale-bar interaction lifecycle checks. +- `buildtool checkProject` verifies `LabKit.prj` path and startup metadata. +- `buildtool packageDryRun` writes a package-boundary inventory under + `artifacts/package/` without exporting a toolbox. + Default non-GUI suite: ```bash @@ -30,7 +54,9 @@ If local execution policy blocks direct `.ps1` execution, run: powershell -ExecutionPolicy Bypass -File .\scripts\run_matlab_tests.ps1 ``` -Both wrappers call `tests/run_all_tests.m` and accept the same `--suite`, `--test`, and `--gui` options. Set `MATLAB_CMD` when MATLAB is not on `PATH`, and set `MATLAB_TEST_LOG` to override the default `matlab_test.log` location. +Both wrappers call `tests/runLabKitTests.m` and accept the same `--suite`, +`--test`, and `--gui` options. Set `MATLAB_CMD` when MATLAB is not on `PATH`, +and set `MATLAB_TEST_LOG` to override the default `matlab_test.log` location. ## Validation Levels @@ -40,7 +66,10 @@ Both wrappers call `tests/run_all_tests.m` and accept the same `--suite`, `--tes | Focused GUI suite runs | Local MATLAB with graphics support | Noninteractive launch, layout, and callback wiring checks for selected app families. | | Manual GUI validation | User-run app windows | Interactive file selection, drawing, visual inspection, and full workflow feel. | -CI runs the default non-GUI suite through `.github/workflows/matlab-tests.yml`. It should not be described as full GUI workflow validation. +CI runs quality, unit/coverage, and integration jobs on pushes and pull +requests to `main` through `.github/workflows/matlab-tests.yml`. Manual and +scheduled CI runs also execute GUI structural and non-blocking GUI gesture jobs. +Do not describe CI as full interactive GUI workflow validation. ## Focused Suites @@ -95,28 +124,26 @@ UI framework changes should cover the affected layer rather than only the change | Runtime/tools | `labkit/ui --gui` runtime, anchor-editor, and scale-bar tool tests. | | Diagnostics | `labkit/ui --gui` debug instrumentation tests plus `apps/smoke --gui` debug launch trace checks. | | App migration | Affected `apps/ --gui` suite plus `project` entrypoint/boundary guardrails. | +| Gesture tools | `buildtool testGuiGesture` for runtime, anchor-editor, and scale-bar lifecycle checks. | ## Suite Layout Tests live under: ```text -tests/suites/project -tests/suites/labkit/dta -tests/suites/labkit/biosignal -tests/suites/labkit/ui -tests/suites/apps/electrochem -tests/suites/apps/dic -tests/suites/apps/image_measurement -tests/suites/apps/wearable -tests/suites/apps/smoke +tests/unit/ +tests/integration/ +tests/gui/ ``` -The stable entry point is `tests/run_all_tests.m`. It discovers `test_*.m` files directly from `tests/suites//`, so adding a focused test normally only requires placing it in the appropriate target folder. +Official `matlab.unittest` tests live under `tests/unit` and +`tests/integration`. Noninteractive GUI structural and gesture tests live under +`tests/gui` and use `matlab.uitest.TestCase` when they launch app windows or +interact with controls. Shared setup, structural GUI assertions, and focused support routines live under `tests/helpers/`. Keep helpers limited to setup and assertions; app-specific formulas, result schemas, export formats, and expected scientific values should remain in focused suite tests. -Architecture guardrails are split by concern under `tests/suites/project/`: public package surface, reusable package dependency boundaries, app entrypoint boundaries, and app-owned workflow boundaries. These guardrails may require workflow code to remain under the owning app tree, but they should not require GUI-free helpers to stay in the public app entry-point file. App-private helpers are checked by boundary rules rather than exact file-list assertions. +Architecture guardrails are split by concern under `tests/integration/project/`: public package surface, reusable package dependency boundaries, app entrypoint boundaries, and app-owned workflow boundaries. These guardrails may require workflow code to remain under the owning app tree, but they should not require GUI-free helpers to stay in the public app entry-point file. App-private helpers are checked by boundary rules rather than exact file-list assertions. When a suite file becomes broad enough that unrelated changes must read hundreds of lines, add a narrower `test_*.m` file in the same suite instead of appending more coverage to the broad file. diff --git a/docs/ui.md b/docs/ui.md index 3a5ff6c..7668cd5 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -114,7 +114,7 @@ runtime = labkit.ui.tool.createRuntime(ax, struct( ... 'onTrace', debug.trace)); ``` -The runtime owns exclusive sessions, pointer callbacks, drag capture, scroll ownership, and restoration. Apps should not set `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or image-tool `ButtonDownFcn` directly. +The runtime owns exclusive sessions, pointer callbacks, drag capture, scroll ownership, and restoration. Temporary drag callbacks are cleared on normal release and on callback errors before errors are rethrown. Apps should not set `WindowScrollWheelFcn`, `WindowButtonMotionFcn`, `WindowButtonUpFcn`, or image-tool `ButtonDownFcn` directly. Use `labkit.ui.tool.anchorEditor(runtime, imageSize, opts)` for generic anchor editing. Use `labkit.ui.tool.scaleBar(parent, row, runtime, opts)` for calibration controls, reference-pixel editing, unit normalization, final scale-bar placement, and overlay drawing. Apps still own image loading, redraw order, scientific calculations, result summaries, alerts, logs, and exports. @@ -122,14 +122,14 @@ Use `labkit.ui.tool.anchorEditor(runtime, imageSize, opts)` for generic anchor e ## Diagnostics -Apps route internal test/debug launch through: +Apps route debug launch requests through: ```matlab [handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... - appName, varargin, nargout, handlers); + appName, varargin, nargout); ``` -Debug contexts are created by dispatch for normal app entry points. Apps with nonstandard request paths may call `labkit.ui.diag.createContext(appName, opts)` directly. +Debug contexts are created by dispatch for normal app entry points. Non-debug string inputs are rejected by the public app launch path. Apps with nonstandard request paths may call `labkit.ui.diag.createContext(appName, opts)` directly. Debug launches support: diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQd.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQd.xml new file mode 100644 index 0000000..5dc80ec --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQp.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQp.xml new file mode 100644 index 0000000..fa67eaf --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/6CjpE-DBB-XLWUFPlH6J7ApeTJQp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08d.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08d.xml new file mode 100644 index 0000000..687262b --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08p.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08p.xml new file mode 100644 index 0000000..195966c --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/D2Q7pBwk1zMJO8on0EMVQFsky08p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAd.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAd.xml new file mode 100644 index 0000000..adfad4e --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAp.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAp.xml new file mode 100644 index 0000000..74a4558 --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/IIuZeUvQQNBHMP8oXH0fybflVfAp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnId.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnId.xml new file mode 100644 index 0000000..5882b4b --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnId.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnIp.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnIp.xml new file mode 100644 index 0000000..e056be6 --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/LLfQ1zSiJzNRjafj3EYZKkhOEnIp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38d.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38d.xml new file mode 100644 index 0000000..fd674ff --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38p.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38p.xml new file mode 100644 index 0000000..93904af --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/PEaOjrJdI2VqpRtIaUZm4J56I38p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAd.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAd.xml new file mode 100644 index 0000000..cdddfcf --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAp.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAp.xml new file mode 100644 index 0000000..cec7afa --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/hLpwSXDG-5W-wYRv7zi_MHVo7qAp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcd.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcd.xml new file mode 100644 index 0000000..94a29ec --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcp.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcp.xml new file mode 100644 index 0000000..47e7fcc --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/o_lfbn28YvTglrZ0ZUmIanrJFZcp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ed.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ed.xml new file mode 100644 index 0000000..064d543 --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ed.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ep.xml b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ep.xml new file mode 100644 index 0000000..436fab3 --- /dev/null +++ b/resources/project/EEtUlUb-dLAdf0KpMVivaUlztwA/zj8WUSkF1902gCTm0ZbmCQwJF9Ep.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMd.xml b/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMd.xml new file mode 100644 index 0000000..46ab33e --- /dev/null +++ b/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMp.xml b/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMp.xml new file mode 100644 index 0000000..58acdd6 --- /dev/null +++ b/resources/project/HoHDHQ_WvHAAKj5aJOrvrg_vpt8/xXlmKuOQ7YT_G1elNhbKQIUqSRMp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMd.xml b/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMd.xml new file mode 100644 index 0000000..0ddb9b0 --- /dev/null +++ b/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMp.xml b/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMp.xml new file mode 100644 index 0000000..1d128fa --- /dev/null +++ b/resources/project/KAXfQgCar2Yb8zOxgvf9hdmLP1E/SbR51NXlw6OOaE9tjeqiyE_ePgMp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4d.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4d.xml new file mode 100644 index 0000000..6d1c43c --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4p.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4p.xml new file mode 100644 index 0000000..e993c77 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/2kj09UetkV_lru3gvSPXnY6-nM4p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8d.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8d.xml new file mode 100644 index 0000000..d47011f --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8p.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8p.xml new file mode 100644 index 0000000..91b0acc --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/KKyDJtbdIBOlaeHmIZd5VX6vqx8p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4d.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4d.xml new file mode 100644 index 0000000..6c16a34 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4d.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4p.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4p.xml new file mode 100644 index 0000000..76301e1 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/QWNDYJD5mGW1bWYvPx9DtKnxzw4p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Id.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Id.xml new file mode 100644 index 0000000..e228479 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Id.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Ip.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Ip.xml new file mode 100644 index 0000000..958c22f --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/R1RggVhA72agIvELiuhWPRS8F0Ip.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZod.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZod.xml new file mode 100644 index 0000000..b5689bd --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZod.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZop.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZop.xml new file mode 100644 index 0000000..ffb1fe8 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/aEHSZBIY-yve10yGis12Zr5DLZop.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencd.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencd.xml new file mode 100644 index 0000000..646977e --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencp.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencp.xml new file mode 100644 index 0000000..2e052d9 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/j4xwF_j8iFTVayUMfxLgMnTbencp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkd.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkd.xml new file mode 100644 index 0000000..c67e567 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkp.xml b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkp.xml new file mode 100644 index 0000000..880a245 --- /dev/null +++ b/resources/project/NjSPEMsIuLUyIpr2u1Js5bVPsOs/r8LR4nLmg9ai3oHrW1r_-KocQzkp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/Project.xml b/resources/project/Project.xml new file mode 100644 index 0000000..62d05aa --- /dev/null +++ b/resources/project/Project.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsd.xml b/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsd.xml new file mode 100644 index 0000000..5de8c3e --- /dev/null +++ b/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsp.xml b/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsp.xml new file mode 100644 index 0000000..642c7d7 --- /dev/null +++ b/resources/project/fjRQtWiSIy7hIlj-Kmk87M7s21k/NjSPEMsIuLUyIpr2u1Js5bVPsOsp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24d.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24d.xml new file mode 100644 index 0000000..99772b4 --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24d.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24p.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24p.xml new file mode 100644 index 0000000..71c9776 --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/BEEQxQR6CyW4y_cy_oNZgskne24p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAd.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAd.xml new file mode 100644 index 0000000..4356a6a --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAp.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAp.xml new file mode 100644 index 0000000..77329db --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/TMK4UzWHdRLhy_w-CHt9y11Q8XAp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkd.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkd.xml new file mode 100644 index 0000000..4356a6a --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkp.xml b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkp.xml new file mode 100644 index 0000000..603491d --- /dev/null +++ b/resources/project/qaw0eS1zuuY1ar9TdPn1GMfrjbQ/qD-kr16wmwlzR-nIg1IG_vvRrWkp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/EEtUlUb-dLAdf0KpMVivaUlztwAp.xml b/resources/project/root/EEtUlUb-dLAdf0KpMVivaUlztwAp.xml new file mode 100644 index 0000000..fee2cd2 --- /dev/null +++ b/resources/project/root/EEtUlUb-dLAdf0KpMVivaUlztwAp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQd.xml b/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQd.xml new file mode 100644 index 0000000..087d24b --- /dev/null +++ b/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQd.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQp.xml b/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQp.xml new file mode 100644 index 0000000..2037c33 --- /dev/null +++ b/resources/project/root/GiiBklLgTxteCEmomM8RCvWT0nQp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/HoHDHQ_WvHAAKj5aJOrvrg_vpt8p.xml b/resources/project/root/HoHDHQ_WvHAAKj5aJOrvrg_vpt8p.xml new file mode 100644 index 0000000..262c3fe --- /dev/null +++ b/resources/project/root/HoHDHQ_WvHAAKj5aJOrvrg_vpt8p.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/KAXfQgCar2Yb8zOxgvf9hdmLP1Ep.xml b/resources/project/root/KAXfQgCar2Yb8zOxgvf9hdmLP1Ep.xml new file mode 100644 index 0000000..e016e25 --- /dev/null +++ b/resources/project/root/KAXfQgCar2Yb8zOxgvf9hdmLP1Ep.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/fjRQtWiSIy7hIlj-Kmk87M7s21kp.xml b/resources/project/root/fjRQtWiSIy7hIlj-Kmk87M7s21kp.xml new file mode 100644 index 0000000..a4de013 --- /dev/null +++ b/resources/project/root/fjRQtWiSIy7hIlj-Kmk87M7s21kp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/root/qaw0eS1zuuY1ar9TdPn1GMfrjbQp.xml b/resources/project/root/qaw0eS1zuuY1ar9TdPn1GMfrjbQp.xml new file mode 100644 index 0000000..8b0d336 --- /dev/null +++ b/resources/project/root/qaw0eS1zuuY1ar9TdPn1GMfrjbQp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/project/rootp.xml b/resources/project/rootp.xml new file mode 100644 index 0000000..4356a6a --- /dev/null +++ b/resources/project/rootp.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/scripts/run_matlab_tests.ps1 b/scripts/run_matlab_tests.ps1 index 382a5fa..058790d 100644 --- a/scripts/run_matlab_tests.ps1 +++ b/scripts/run_matlab_tests.ps1 @@ -3,9 +3,8 @@ Runs the LabKit MATLAB test suite from Windows PowerShell. .DESCRIPTION -This is the Windows-native wrapper for tests/run_all_tests.m. It mirrors the -options accepted by scripts/run_matlab_tests.sh while avoiding a dependency on -Bash or Unix-only MATLAB startup flags. +This is the Windows-native wrapper for tests/runLabKitTests.m. It runs the +official matlab.unittest and matlab.uitest suites. #> $ErrorActionPreference = 'Stop' @@ -183,7 +182,7 @@ $suiteCell = ConvertTo-MatlabCell $Suites $testCell = ConvertTo-MatlabCell $Tests $includeGuiText = if ($IncludeGui) { 'true' } else { 'false' } $selectionExpr = "struct('suites', {$suiteCell}, 'tests', {$testCell})" -$testExpr = "run_all_tests($includeGuiText, $selectionExpr);" +$testExpr = "runLabKitTests('IncludeGui', $includeGuiText, 'Suites', $suiteCell, 'Tests', $testCell, 'FailIfNoTests', false);" $matlabCommand = "cd($(ConvertTo-MatlabStringLiteral $rootPath)); addpath(fullfile(pwd, 'tests')); $testExpr" $flagSource = if ($IncludeGui) { $env:MATLAB_GUI_FLAGS } else { $env:MATLAB_FLAGS } diff --git a/scripts/run_matlab_tests.sh b/scripts/run_matlab_tests.sh index 9d83ad4..9f70318 100755 --- a/scripts/run_matlab_tests.sh +++ b/scripts/run_matlab_tests.sh @@ -17,8 +17,9 @@ Options: This mode requires MATLAB graphics/uifigure support and does not use the default headless -nojvm/-nodisplay/-noFigureWindows flags. --suite NAME Run only a suite target, for example labkit/dta or apps/electrochem. Repeatable. - Suite targets are directories under tests/suites; selecting a - parent target such as labkit or apps includes child suites. + Suite targets mirror official tests/unit, tests/integration, + and tests/gui ownership; parent targets such as labkit or apps + include child suites. The special gui target selects all GUI tests. --test NAME Run only a test function, for example test_gui_layout_ui_anchor_curve_editor. Repeatable. test_gui_* automatically uses GUI MATLAB flags. @@ -127,10 +128,10 @@ else fi if [[ "$INCLUDE_GUI" -eq 1 ]]; then MATLAB_FLAGS="${MATLAB_GUI_FLAGS:-}" - TEST_EXPR="run_all_tests(true, struct('suites', {$SUITE_CELL}, 'tests', {$TEST_CELL}));" + TEST_EXPR="runLabKitTests('IncludeGui', true, 'Suites', $SUITE_CELL, 'Tests', $TEST_CELL, 'FailIfNoTests', false);" else MATLAB_FLAGS="${MATLAB_FLAGS:--nojvm -nodisplay -noFigureWindows}" - TEST_EXPR="run_all_tests(false, struct('suites', {$SUITE_CELL}, 'tests', {$TEST_CELL}));" + TEST_EXPR="runLabKitTests('IncludeGui', false, 'Suites', $SUITE_CELL, 'Tests', $TEST_CELL, 'FailIfNoTests', false);" fi MATLAB_FLAG_ARGS=() if [[ -n "$MATLAB_FLAGS" ]]; then diff --git a/tests/AGENTS.md b/tests/AGENTS.md index d4b47e0..8ff90ac 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -6,13 +6,18 @@ Tests mirror source ownership. Do not create a parallel runner framework unless - `docs/testing.md` - affected source files -- nearby tests under `tests/suites//` +- nearby tests under `tests/unit/`, `tests/integration/`, or `tests/gui/` ## Test Layout -- Add focused tests under `tests/suites//test_*.m`. +- Add tests under `tests/unit/`, `tests/integration/`, or `tests/gui/` using + `matlab.unittest` or `matlab.uitest` styles. +- Do not add a separate custom runner or direct pass/fail test tree; route + coverage through `tests/runLabKitTests.m` and build tasks. - Keep architecture guardrails in the narrowest project-suite file that matches the concern. - Use `tests/helpers/` only for setup, lookup, assertion, cleanup, and fixture-building helpers. +- Use `tests/support/` for official-runner setup, artifact paths, structured + trace capture, GUI fixture setup, and component snapshots. - Do not move app-specific formulas, expected scientific values, result schemas, or export columns into shared test helpers. - Boundary tests may require app-owned logic to stay under the owning app tree, but should not require GUI-free helpers to remain inside the public app entry-point file or assert exact app-private helper file lists. - UI public-surface tests should assert the layered `labkit.ui.app/view/tool/diag` facade and keep low-level controls, row resize, panel internals, and popout implementation private. diff --git a/tests/gui/gesture/labkit/ui/AnchorEditorGestureTest.m b/tests/gui/gesture/labkit/ui/AnchorEditorGestureTest.m new file mode 100644 index 0000000..b1b3b3d --- /dev/null +++ b/tests/gui/gesture/labkit/ui/AnchorEditorGestureTest.m @@ -0,0 +1,84 @@ +classdef AnchorEditorGestureTest < matlab.uitest.TestCase + %ANCHOREDITORGESTURETEST Gesture-level anchor editor operation coverage. + + methods (Test, TestTags = {'GUI', 'Gesture'}) + function anchorOperationsEmitStructuredTrace(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); %#ok + + fig = uifigure('Visible', 'off', 'Name', 'labkit_anchor_gesture_probe'); + cleaner = onCleanup(@() delete(fig)); %#ok + ax = uiaxes(fig); + image(ax, zeros(50, 70, 3, 'uint8')); + axis(ax, 'image'); + + recorder = createLabKitTraceRecorder( ... + "AppName", "labkit_ui", ... + "TestName", "AnchorEditorGestureTest", ... + "RunId", "phase7-anchor-gesture"); + traceSink = createLabKitToolTraceSink(recorder); + runtime = labkit.ui.tool.createRuntime(ax, struct( ... + 'figure', fig, ... + 'onTrace', traceSink)); + + changedReasons = strings(0, 1); + editor = labkit.ui.tool.anchorEditor(runtime, [50 70 3], ... + struct('closed', false, ... + 'style', 'Straight lines', ... + 'onTrace', traceSink, ... + 'onChanged', @onChanged)); + editor.start([8 8; 24 18]); + startedPoints = editor.getPoints(); + assert(startedPoints(1, 1) == 8 && startedPoints(2, 1) == 24, ... + 'Anchor editor should start with the provided points.'); + + editor.insertPoint([36 18]); + points = editor.getPoints(); + assert(isequal(size(points), [3 2]), ... + 'Anchor insert operation should add a point.'); + + editor.undoLast(); + assert(isequal(size(editor.getPoints()), [2 2]), ... + 'Anchor undo operation should remove the last point.'); + + editor.setStyle('Curve'); + editor.setStyle('Curve'); + editor.clearPoints(); + assert(isempty(editor.getPoints()), ... + 'Anchor clear operation should remove all points.'); + editor.delete(); + + events = recorder.events(); + assertHasEvent(events, "anchorEditor", "edit.start"); + assertHasEvent(events, "anchorEditor", "anchor.insert"); + assertHasEvent(events, "anchorEditor", "anchor.undo"); + assertHasEvent(events, "anchorEditor", "anchor.clear"); + assertHasEvent(events, "anchorEditor", "style.noop"); + assertHasEvent(events, "runtime", "session.activate"); + assertHasEvent(events, "runtime", "session.deactivate"); + assert(any(changedReasons == "add point") && any(changedReasons == "undo point") && ... + any(changedReasons == "clear points"), ... + 'Anchor editor should emit semantic change reasons for add, undo, and clear operations.'); + writeGestureArtifacts(recorder, fig, "anchor_editor_gesture"); + + function onChanged(~, reason) + changedReasons(end+1, 1) = string(reason); %#ok + end + end + end +end + +function assertHasEvent(events, component, eventName) + assert(any(string({events.component}) == component & string({events.event}) == eventName), ... + 'Missing structured event %s/%s.', component, eventName); +end + +function writeGestureArtifacts(recorder, fig, name) + paths = labkitArtifactPaths("Create", true); + recorder.writeJsonl(fullfile(paths.guiTrace, name + ".jsonl")); + recorder.writeText(fullfile(paths.guiTrace, name + ".txt")); + writeLabKitJsonlArtifact(fullfile(paths.guiSnapshots, name + "_components.jsonl"), ... + snapshotLabKitComponents(fig)); +end diff --git a/tests/gui/gesture/labkit/ui/RuntimeGestureTest.m b/tests/gui/gesture/labkit/ui/RuntimeGestureTest.m new file mode 100644 index 0000000..16fd21a --- /dev/null +++ b/tests/gui/gesture/labkit/ui/RuntimeGestureTest.m @@ -0,0 +1,126 @@ +classdef RuntimeGestureTest < matlab.uitest.TestCase + %RUNTIMEGESTURETEST Gesture-level checks for image axes runtime ownership. + + methods (Test, TestTags = {'GUI', 'Gesture'}) + function sessionsRestoreCallbacksAndEmitTrace(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); %#ok + + fig = uifigure('Visible', 'off', 'Name', 'labkit_runtime_gesture_probe'); + cleaner = onCleanup(@() delete(fig)); %#ok + ax = uiaxes(fig); + bg = image(ax, zeros(30, 40, 3, 'uint8')); + axis(ax, 'image'); + + recorder = createLabKitTraceRecorder( ... + "AppName", "labkit_ui", ... + "TestName", "RuntimeGestureTest", ... + "RunId", "phase7-runtime-gesture"); + traceSink = createLabKitToolTraceSink(recorder); + + interactionStates = strings(0, 1); + defaultScroll = @(~,~) setappdata(fig, 'defaultScrollCalled', true); + runtime = labkit.ui.tool.createRuntime(ax, struct( ... + 'figure', fig, ... + 'defaultScrollFcn', defaultScroll, ... + 'onTrace', traceSink, ... + 'onInteractionChanged', @onInteractionChanged)); + assert(isequal(fig.WindowScrollWheelFcn, defaultScroll), ... + 'Runtime should install the app default scroll callback.'); + + sessionA = runtime.createSession(struct( ... + 'name', 'firstGesture', ... + 'onPointerDown', @(~,~) setappdata(fig, 'firstPointer', true), ... + 'onScroll', @(~,~) setappdata(fig, 'firstScroll', true))); + sessionA.setBackground(bg); + sessionA.activate(); + assert(sessionA.isActive() && runtime.isInteractionActive(), ... + 'First session should become active.'); + assert(~isempty(ax.ButtonDownFcn) && strcmp(bg.HitTest, 'on'), ... + 'Active session should own axes/background pointer callbacks.'); + + sessionB = runtime.createSession(struct( ... + 'name', 'secondGesture', ... + 'onPointerDown', @(~,~) setappdata(fig, 'secondPointer', true), ... + 'onScroll', @(~,~) setappdata(fig, 'secondScroll', true))); + sessionB.activate(); + assert(~sessionA.isActive() && sessionB.isActive(), ... + 'Activating a second session should deactivate the first session.'); + assert(strcmp(bg.HitTest, 'off') && strcmp(bg.PickableParts, 'none'), ... + 'Peer deactivation should release the first session background hit testing.'); + + dragMotionCalls = 0; + dragReleaseCalls = 0; + sessionB.captureDrag(@onDragMotion, @onDragRelease); + assert(~isempty(fig.WindowButtonMotionFcn) && ~isempty(fig.WindowButtonUpFcn), ... + 'Drag capture should install temporary figure callbacks.'); + fig.WindowButtonMotionFcn(fig, struct()); + fig.WindowButtonUpFcn(fig, struct()); + assert(dragMotionCalls == 1 && dragReleaseCalls == 1, ... + 'Normal drag callbacks should be invoked once.'); + assert(isempty(fig.WindowButtonMotionFcn) && isempty(fig.WindowButtonUpFcn), ... + 'Normal drag release should clear temporary figure callbacks.'); + + sessionB.captureDrag(@onDragError, []); + didThrow = false; + try + fig.WindowButtonMotionFcn(fig, struct()); + catch ME + didThrow = strcmp(ME.identifier, 'labkit:Test:DragFailure'); + end + assert(didThrow, 'Runtime should rethrow drag callback errors.'); + assert(isempty(fig.WindowButtonMotionFcn) && isempty(fig.WindowButtonUpFcn), ... + 'Drag callback errors should still clear temporary figure callbacks.'); + + sessionB.deactivate(); + assert(~runtime.isInteractionActive(), ... + 'Runtime should report no active interaction after deactivation.'); + assert(isequal(fig.WindowScrollWheelFcn, defaultScroll), ... + 'Session deactivation should restore the runtime default scroll callback.'); + runtime.delete(); + assert(isempty(ax.ButtonDownFcn) && isempty(fig.WindowScrollWheelFcn), ... + 'Runtime deletion should restore pre-runtime axes and figure callbacks.'); + + events = recorder.events(); + assertHasEvent(events, "runtime", "session.activate"); + assertHasEvent(events, "runtime", "session.peerDeactivate"); + assertHasEvent(events, "runtime", "drag.capture"); + assertHasEvent(events, "runtime", "drag.release"); + assertHasEvent(events, "runtime", "drag.motionError"); + assert(any(contains(interactionStates, "true:secondGesture")), ... + 'Runtime should report active interaction state for the second session.'); + writeGestureArtifacts(recorder, fig, "runtime_gesture"); + + function onInteractionChanged(active, name) + interactionStates(end+1, 1) = string(logical(active)) + ":" + string(name); %#ok + end + + function onDragMotion(~, ~) + dragMotionCalls = dragMotionCalls + 1; + end + + function onDragRelease(~, ~) + dragReleaseCalls = dragReleaseCalls + 1; + end + + function onDragError(~, ~) + error('labkit:Test:DragFailure', 'Synthetic drag failure.'); + end + end + end +end + +function assertHasEvent(events, component, eventName) + assert(any(string({events.component}) == component & string({events.event}) == eventName), ... + 'Missing structured event %s/%s.', component, eventName); +end + +function writeGestureArtifacts(recorder, fig, name) + paths = labkitArtifactPaths("Create", true); + recorder.writeJsonl(fullfile(paths.guiTrace, name + ".jsonl")); + recorder.writeText(fullfile(paths.guiTrace, name + ".txt")); + writeLabKitJsonlArtifact(fullfile(paths.guiSnapshots, name + "_components.jsonl"), ... + snapshotLabKitComponents(fig)); +end diff --git a/tests/gui/gesture/labkit/ui/ScaleBarGestureTest.m b/tests/gui/gesture/labkit/ui/ScaleBarGestureTest.m new file mode 100644 index 0000000..b3fcbfb --- /dev/null +++ b/tests/gui/gesture/labkit/ui/ScaleBarGestureTest.m @@ -0,0 +1,116 @@ +classdef ScaleBarGestureTest < matlab.uitest.TestCase + %SCALEBARGESTURETEST Gesture-level scale-bar lifecycle coverage. + + methods (Test, TestTags = {'GUI', 'Gesture'}) + function referenceEditAndPlacementEmitStructuredTrace(testCase) + setupLabKitTestPath(); + h = guiTestHelpers(); + h.assertUifigureAvailable(); + cleanup = onCleanup(@() h.closeAllFigures()); %#ok + + fig = uifigure('Visible', 'off', 'Name', 'labkit_scale_bar_gesture_probe'); + cleaner = onCleanup(@() delete(fig)); %#ok + grid = uigridlayout(fig, [2 1]); + ax = uiaxes(grid); + ax.Layout.Row = 1; + bg = imagesc(ax, rand(80, 120)); + axis(ax, 'image'); + + recorder = createLabKitTraceRecorder( ... + "AppName", "labkit_ui", ... + "TestName", "ScaleBarGestureTest", ... + "RunId", "phase7-scale-bar-gesture"); + traceSink = createLabKitToolTraceSink(recorder); + runtime = labkit.ui.tool.createRuntime(ax, struct( ... + 'figure', fig, ... + 'onTrace', traceSink)); + + callbacks = struct('edit', 0, 'calibration', 0, 'bar', 0, 'placed', 0); + tool = labkit.ui.tool.scaleBar(grid, 2, runtime, ... + struct('onTrace', traceSink, ... + 'onReferenceEditChanged', @onReferenceEditChanged, ... + 'onCalibrationChanged', @onCalibrationChanged, ... + 'onScaleBarChanged', @onScaleBarChanged, ... + 'onScaleBarPlaced', @onScaleBarPlaced)); + tool.setImageSize([80 120 1]); + tool.setBackground(bg); + + tool.setEnabled(struct('hasImage', false)); + assert(strcmp(tool.controls.measureReferenceButton.Enable, 'off'), ... + 'Scale-bar reference editing should be disabled without an image.'); + tool.setEnabled(struct('hasImage', true)); + tool.setEnabled(struct('hasImage', true)); + assert(strcmp(tool.controls.measureReferenceButton.Enable, 'on'), ... + 'Repeated enable should leave reference editing available.'); + + tool.setReferencePixels(40); + tool.setReferencePixels(40); + tool.controls.referenceLengthSpinner.Value = 10; + tool.controls.unitDropdown.Value = 'mm'; + h.invokeCallback(tool.controls.unitDropdown, 'ValueChangedFcn'); + cal = tool.calibration(); + assert(cal.isCalibrated && cal.pixelsPerUnit == 4, ... + 'Repeated same-value reference pixels should leave calibration stable.'); + + h.invokeCallback(tool.controls.measureReferenceButton, 'ButtonPushedFcn'); + assert(tool.isReferenceEditActive() && strcmp(tool.controls.measureReferenceButton.Text, ... + 'Finish reference edit'), ... + 'Measure reference should start reference edit mode.'); + h.invokeCallback(tool.controls.measureReferenceButton, 'ButtonPushedFcn'); + assert(~tool.isReferenceEditActive() && strcmp(tool.controls.measureReferenceButton.Text, ... + 'Measure reference pixels'), ... + 'Second measure reference click should finish reference edit mode.'); + + tool.controls.barLengthSpinner.Value = 5; + h.invokeCallback(tool.controls.barLengthSpinner, 'ValueChangedFcn'); + h.invokeCallback(tool.controls.placeButton, 'ButtonPushedFcn'); + assert(tool.hasScaleBar() && callbacks.placed == 1 && callbacks.bar >= 1, ... + 'Place scale bar should store a bar and emit app-facing callbacks.'); + handles = tool.renderOverlay(ax); + assert(isstruct(handles) && isvalid(handles.line) && isvalid(handles.label), ... + 'Placed scale bar should render overlay handles.'); + tool.delete(); + + events = recorder.events(); + assertHasEvent(events, "scaleBar", "enabled.set"); + assertHasEvent(events, "scaleBar", "referencePixels.set"); + assertHasEvent(events, "scaleBar", "referenceEdit.start"); + assertHasEvent(events, "scaleBar", "referenceEdit.finish"); + assertHasEvent(events, "scaleBar", "scaleBar.place"); + assertHasEvent(events, "runtime", "session.activate"); + assertHasEvent(events, "runtime", "session.deactivate"); + assert(callbacks.edit >= 2 && callbacks.calibration >= 1, ... + 'Scale-bar lifecycle should emit reference edit and calibration callbacks.'); + writeGestureArtifacts(recorder, fig, "scale_bar_gesture"); + + function onReferenceEditChanged(~, ~) + callbacks.edit = callbacks.edit + 1; + end + + function onCalibrationChanged(~, ~) + callbacks.calibration = callbacks.calibration + 1; + end + + function onScaleBarChanged(~, ~) + callbacks.bar = callbacks.bar + 1; + end + + function onScaleBarPlaced(~, ~) + callbacks.placed = callbacks.placed + 1; + end + end + end +end + +function assertHasEvent(events, component, eventName) + assert(any(string({events.component}) == component & string({events.event}) == eventName), ... + 'Missing structured event %s/%s.', component, eventName); +end + +function writeGestureArtifacts(recorder, fig, name) + paths = labkitArtifactPaths("Create", true); + recorder.writeJsonl(fullfile(paths.guiTrace, name + ".jsonl")); + recorder.writeText(fullfile(paths.guiTrace, name + ".txt")); + writeLabKitJsonlArtifact(fullfile(paths.guiSnapshots, name + "_components.jsonl"), ... + snapshotLabKitComponents(fig)); +end diff --git a/tests/suites/apps/dic/test_gui_layout_dic.m b/tests/gui/structural/apps/dic/LegacyGuiLayoutDicTest.m similarity index 86% rename from tests/suites/apps/dic/test_gui_layout_dic.m rename to tests/gui/structural/apps/dic/LegacyGuiLayoutDicTest.m index 59d9a5f..12b7f64 100644 --- a/tests/suites/apps/dic/test_gui_layout_dic.m +++ b/tests/gui/structural/apps/dic/LegacyGuiLayoutDicTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_dic() +classdef LegacyGuiLayoutDicTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTDICTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_dic(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_dic(); + end + end +end + +function legacy_test_gui_layout_dic() %TEST_GUI_LAYOUT_DIC Verify DIC GUI layout contracts. h = guiTestHelpers(); diff --git a/tests/suites/apps/electrochem/test_gui_layout_electrochem.m b/tests/gui/structural/apps/electrochem/LegacyGuiLayoutElectrochemTest.m similarity index 87% rename from tests/suites/apps/electrochem/test_gui_layout_electrochem.m rename to tests/gui/structural/apps/electrochem/LegacyGuiLayoutElectrochemTest.m index 3ac93de..e633603 100644 --- a/tests/suites/apps/electrochem/test_gui_layout_electrochem.m +++ b/tests/gui/structural/apps/electrochem/LegacyGuiLayoutElectrochemTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_electrochem() +classdef LegacyGuiLayoutElectrochemTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTELECTROCHEMTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_electrochem(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_electrochem(); + end + end +end + +function legacy_test_gui_layout_electrochem() %TEST_GUI_LAYOUT_ELECTROCHEM Verify electrochemistry GUI layout contracts. h = guiTestHelpers(); @@ -8,7 +19,6 @@ function test_gui_layout_electrochem() checkMultiDTA(h); checkEIS(h); checkCVCSC(h); - checkCVCSCFixtureLoad(); checkVTResistance(h); checkCIC(h); end @@ -71,20 +81,6 @@ function checkCVCSC(h) h.invokeButton(fig, 'Clear Both'); end -function checkCVCSCFixtureLoad() - fixture = dtaFixturePath('cv_cyclic_voltammetry_pt_reference.DTA'); - diagnostics = labkit_CSC_app('__labkit_test__', 'loadFileDiagnostics', fixture); - - assert(strcmp(diagnostics.file, fixture), 'CSC load should update the selected file field.'); - assert(~isempty(diagnostics.curveItems) && ~strcmp(diagnostics.curveItems{1}, '(none)'), ... - 'CSC load should populate parsed curve items.'); - assert(diagnostics.topLineCount >= 1, 'CSC load should render at least one top plot line.'); - assert(diagnostics.bottomLineCount >= 1, 'CSC load should render at least one bottom plot line.'); - assert(contains(diagnostics.qct, 'C'), 'CSC load should compute and display CT charge.'); - assert(contains(diagnostics.qcv, 'C'), 'CSC load should compute and display CV charge.'); - assert(~contains(diagnostics.status, 'Ready'), 'CSC load should update status after analysis.'); -end - function checkVTResistance(h) fig = h.launchFigure('labkit_VTResistance_app', 'Gamry VT Steady Resistance GUI'); h.assertFigureMinimumSize(fig, 1600, 900); diff --git a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m b/tests/gui/structural/apps/image_measurement/LegacyGuiLayoutImageMeasurementTest.m similarity index 89% rename from tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m rename to tests/gui/structural/apps/image_measurement/LegacyGuiLayoutImageMeasurementTest.m index 0219df6..07918a2 100644 --- a/tests/suites/apps/image_measurement/test_gui_layout_image_measurement.m +++ b/tests/gui/structural/apps/image_measurement/LegacyGuiLayoutImageMeasurementTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_image_measurement() +classdef LegacyGuiLayoutImageMeasurementTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTIMAGEMEASUREMENTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_image_measurement(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_image_measurement(); + end + end +end + +function legacy_test_gui_layout_image_measurement() %TEST_GUI_LAYOUT_IMAGE_MEASUREMENT Verify image-measurement GUI layout contracts. h = guiTestHelpers(); diff --git a/tests/suites/apps/smoke/test_gui_smoke.m b/tests/gui/structural/apps/smoke/LegacyGuiSmokeTest.m similarity index 88% rename from tests/suites/apps/smoke/test_gui_smoke.m rename to tests/gui/structural/apps/smoke/LegacyGuiSmokeTest.m index a1546b6..dad61c5 100644 --- a/tests/suites/apps/smoke/test_gui_smoke.m +++ b/tests/gui/structural/apps/smoke/LegacyGuiSmokeTest.m @@ -1,4 +1,15 @@ -function test_gui_smoke() +classdef LegacyGuiSmokeTest < matlab.uitest.TestCase + %LEGACYGUISMOKETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural', 'Smoke'}) + function test_gui_smoke(testCase) + setupLabKitTestPath(); + legacy_test_gui_smoke(); + end + end +end + +function legacy_test_gui_smoke() %TEST_GUI_SMOKE Verify GUI entry points can launch. assertUifigureAvailable(); diff --git a/tests/suites/apps/wearable/test_gui_layout_wearable.m b/tests/gui/structural/apps/wearable/LegacyGuiLayoutWearableTest.m similarity index 79% rename from tests/suites/apps/wearable/test_gui_layout_wearable.m rename to tests/gui/structural/apps/wearable/LegacyGuiLayoutWearableTest.m index 73eac99..9d48d28 100644 --- a/tests/suites/apps/wearable/test_gui_layout_wearable.m +++ b/tests/gui/structural/apps/wearable/LegacyGuiLayoutWearableTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_wearable() +classdef LegacyGuiLayoutWearableTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTWEARABLETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_wearable(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_wearable(); + end + end +end + +function legacy_test_gui_layout_wearable() %TEST_GUI_LAYOUT_WEARABLE Verify wearable GUI layout contracts. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_anchor_curve_editor.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAnchorCurveEditorTest.m similarity index 93% rename from tests/suites/labkit/ui/test_gui_layout_ui_anchor_curve_editor.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAnchorCurveEditorTest.m index 31670cf..cfae429 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_anchor_curve_editor.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAnchorCurveEditorTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_anchor_curve_editor() +classdef LegacyGuiLayoutUiAnchorCurveEditorTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIANCHORCURVEEDITORTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_anchor_curve_editor(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_anchor_curve_editor(); + end + end +end + +function legacy_test_gui_layout_ui_anchor_curve_editor() %TEST_GUI_LAYOUT_UI_ANCHOR_CURVE_EDITOR Verify anchor curve editor contracts. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_axes_workbench.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAxesWorkbenchTest.m similarity index 96% rename from tests/suites/labkit/ui/test_gui_layout_ui_axes_workbench.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAxesWorkbenchTest.m index 9079948..dc10750 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_axes_workbench.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiAxesWorkbenchTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_axes_workbench() +classdef LegacyGuiLayoutUiAxesWorkbenchTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIAXESWORKBENCHTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_axes_workbench(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_axes_workbench(); + end + end +end + +function legacy_test_gui_layout_ui_axes_workbench() %TEST_GUI_LAYOUT_UI_AXES_WORKBENCH Verify axes, shell, and plot-control helpers. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_basic_controls.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBasicControlsTest.m similarity index 96% rename from tests/suites/labkit/ui/test_gui_layout_ui_basic_controls.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBasicControlsTest.m index 1472b2b..fc8d7eb 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_basic_controls.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBasicControlsTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_basic_controls() +classdef LegacyGuiLayoutUiBasicControlsTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIBASICCONTROLSTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_basic_controls(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_basic_controls(); + end + end +end + +function legacy_test_gui_layout_ui_basic_controls() %TEST_GUI_LAYOUT_UI_BASIC_CONTROLS Verify basic reusable UI controls/panels. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_busy_state.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBusyStateTest.m similarity index 87% rename from tests/suites/labkit/ui/test_gui_layout_ui_busy_state.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBusyStateTest.m index a4ad027..929191d 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_busy_state.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiBusyStateTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_busy_state() +classdef LegacyGuiLayoutUiBusyStateTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIBUSYSTATETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_busy_state(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_busy_state(); + end + end +end + +function legacy_test_gui_layout_ui_busy_state() %TEST_GUI_LAYOUT_UI_BUSY_STATE Verify runWithBusyState contracts. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_debug_trace.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiDebugTraceTest.m similarity index 92% rename from tests/suites/labkit/ui/test_gui_layout_ui_debug_trace.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiDebugTraceTest.m index 033b1cb..fe200e5 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_debug_trace.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiDebugTraceTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_debug_trace() +classdef LegacyGuiLayoutUiDebugTraceTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIDEBUGTRACETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_debug_trace(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_debug_trace(); + end + end +end + +function legacy_test_gui_layout_ui_debug_trace() %TEST_GUI_LAYOUT_UI_DEBUG_TRACE Verify GUI callback instrumentation for debug logs. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_image_axes_runtime.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiImageAxesRuntimeTest.m similarity index 88% rename from tests/suites/labkit/ui/test_gui_layout_ui_image_axes_runtime.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiImageAxesRuntimeTest.m index ed6b1ec..1226d46 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_image_axes_runtime.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiImageAxesRuntimeTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_image_axes_runtime() +classdef LegacyGuiLayoutUiImageAxesRuntimeTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUIIMAGEAXESRUNTIMETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_image_axes_runtime(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_image_axes_runtime(); + end + end +end + +function legacy_test_gui_layout_ui_image_axes_runtime() %TEST_GUI_LAYOUT_UI_IMAGE_AXES_RUNTIME Verify managed image axes interaction runtime. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_panel.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarPanelTest.m similarity index 92% rename from tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_panel.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarPanelTest.m index 5ef5e7d..b3e4000 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_panel.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarPanelTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_scale_bar_panel() +classdef LegacyGuiLayoutUiScaleBarPanelTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUISCALEBARPANELTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_scale_bar_panel(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_scale_bar_panel(); + end + end +end + +function legacy_test_gui_layout_ui_scale_bar_panel() %TEST_GUI_LAYOUT_UI_SCALE_BAR_PANEL Verify reusable scale-bar panel contracts. h = guiTestHelpers(); diff --git a/tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_tool.m b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarToolTest.m similarity index 94% rename from tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_tool.m rename to tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarToolTest.m index 8ce213a..7e21988 100644 --- a/tests/suites/labkit/ui/test_gui_layout_ui_scale_bar_tool.m +++ b/tests/gui/structural/labkit/ui/LegacyGuiLayoutUiScaleBarToolTest.m @@ -1,4 +1,15 @@ -function test_gui_layout_ui_scale_bar_tool() +classdef LegacyGuiLayoutUiScaleBarToolTest < matlab.uitest.TestCase + %LEGACYGUILAYOUTUISCALEBARTOOLTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'GUI', 'Structural'}) + function test_gui_layout_ui_scale_bar_tool(testCase) + setupLabKitTestPath(); + legacy_test_gui_layout_ui_scale_bar_tool(); + end + end +end + +function legacy_test_gui_layout_ui_scale_bar_tool() %TEST_GUI_LAYOUT_UI_SCALE_BAR_TOOL Verify high-level scale-bar tool contracts. h = guiTestHelpers(); diff --git a/tests/helpers/architectureTestHelpers.m b/tests/helpers/architectureTestHelpers.m index 355ff54..30130ef 100644 --- a/tests/helpers/architectureTestHelpers.m +++ b/tests/helpers/architectureTestHelpers.m @@ -33,6 +33,7 @@ [appName ' implementation should not live in the reusable +labkit package.']); appSource = fileread(appFile); + appOwnedSource = readAppOwnedSource(appFile); assert(contains(appSource, ['function varargout = ' appName]), ... [appName ' should expose one public app entry-point source file.']); assert(~contains(appSource, launchName), ... @@ -50,29 +51,29 @@ [appName ' should not call internal analysis APIs directly.']); assert(~contains(appSource, 'labkit.util.'), ... [appName ' should not call utility APIs directly.']); - assert(contains(appSource, 'labkit.ui.app.createShell'), ... + assert(contains(appOwnedSource, 'labkit.ui.app.createShell'), ... [appName ' should build its GUI from the layered app shell facade.']); - assert(~contains(appSource, 'labkit.ui.create'), ... + assert(~contains(appOwnedSource, 'labkit.ui.create'), ... [appName ' should not call removed flat UI create* helpers.']); - assert(~contains(appSource, 'labkit.ui.appendLog'), ... + assert(~contains(appOwnedSource, 'labkit.ui.appendLog'), ... [appName ' should not call removed flat UI log helpers.']); - assert(~contains(appSource, 'labkit.ui.tabSpec'), ... + assert(~contains(appOwnedSource, 'labkit.ui.tabSpec'), ... [appName ' should not call removed flat UI tab helpers.']); - assert(~contains(appSource, 'labkit.ui.layoutRow'), ... + assert(~contains(appOwnedSource, 'labkit.ui.layoutRow'), ... [appName ' should not call removed flat UI layout helpers.']); - assert(~contains(appSource, 'labkit.ui.runWithBusyState'), ... + assert(~contains(appOwnedSource, 'labkit.ui.runWithBusyState'), ... [appName ' should not call removed flat UI busy-state helpers.']); - assert(~contains(appSource, 'labkit.ui.createWorkbench'), ... + assert(~contains(appOwnedSource, 'labkit.ui.createWorkbench'), ... [appName ' should not call removed flat UI shell helpers.']); - assert(~contains(appSource, 'labkit.ui.handleAppRequest'), ... + assert(~contains(appOwnedSource, 'labkit.ui.handleAppRequest'), ... [appName ' should not call removed flat UI request helpers.']); - assert(~contains(appSource, 'labkit.ui.createAppDebugLog'), ... + assert(~contains(appOwnedSource, 'labkit.ui.createAppDebugLog'), ... [appName ' should not call removed flat UI debug helpers.']); - assert(~contains(appSource, 'labkit.ui.createImageAxesRuntime'), ... + assert(~contains(appOwnedSource, 'labkit.ui.createImageAxesRuntime'), ... [appName ' should not call removed flat UI runtime helpers.']); - assert(~contains(appSource, 'labkit.ui.createStandardWorkbenchShell'), ... + assert(~contains(appOwnedSource, 'labkit.ui.createStandardWorkbenchShell'), ... [appName ' should not use compatibility shell wrappers directly.']); - assert(~contains(appSource, 'labkit.ui.createTabbedDualPlotShell'), ... + assert(~contains(appOwnedSource, 'labkit.ui.createTabbedDualPlotShell'), ... [appName ' should not use compatibility shell wrappers directly.']); forbiddenViewHelpers = {'appendLog', 'clearAxes', 'enablePopout', ... 'fileSelectionPanel', 'logPanel', 'plotOptionsPanel', 'plotXY', ... @@ -81,11 +82,11 @@ 'swapTopBottomPlotSelections', 'textPanel', 'topBottomPlotControls'}; for iHelper = 1:numel(forbiddenViewHelpers) oldViewCall = ['labkit.ui.view.' forbiddenViewHelpers{iHelper}]; - assert(~contains(appSource, oldViewCall), ... + assert(~contains(appOwnedSource, oldViewCall), ... [appName ' should use the unified view panel/draw/update facade instead of ' oldViewCall '.']); end - source = readAppOwnedSource(appFile); + source = appOwnedSource; assert(~contains(source, 'labkit.io.'), ... [appName ' app-owned source should not call low-level IO APIs directly.']); assert(~contains(source, 'labkit.data.'), ... diff --git a/tests/suites/project/test_app_entrypoint_boundaries.m b/tests/integration/project/LegacyAppEntrypointBoundariesTest.m similarity index 88% rename from tests/suites/project/test_app_entrypoint_boundaries.m rename to tests/integration/project/LegacyAppEntrypointBoundariesTest.m index 1052304..9ab1cfb 100644 --- a/tests/suites/project/test_app_entrypoint_boundaries.m +++ b/tests/integration/project/LegacyAppEntrypointBoundariesTest.m @@ -1,4 +1,15 @@ -function test_app_entrypoint_boundaries() +classdef LegacyAppEntrypointBoundariesTest < matlab.unittest.TestCase + %LEGACYAPPENTRYPOINTBOUNDARIESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_app_entrypoint_boundaries(testCase) + setupLabKitTestPath(); + legacy_test_app_entrypoint_boundaries(); + end + end +end + +function legacy_test_app_entrypoint_boundaries() %TEST_APP_ENTRYPOINT_BOUNDARIES Verify app locations and entrypoint shape. root = testRepoRoot(); diff --git a/tests/suites/project/test_app_owned_workflow_boundaries.m b/tests/integration/project/LegacyAppOwnedWorkflowBoundariesTest.m similarity index 95% rename from tests/suites/project/test_app_owned_workflow_boundaries.m rename to tests/integration/project/LegacyAppOwnedWorkflowBoundariesTest.m index fb9a9b4..8ea6807 100644 --- a/tests/suites/project/test_app_owned_workflow_boundaries.m +++ b/tests/integration/project/LegacyAppOwnedWorkflowBoundariesTest.m @@ -1,4 +1,15 @@ -function test_app_owned_workflow_boundaries() +classdef LegacyAppOwnedWorkflowBoundariesTest < matlab.unittest.TestCase + %LEGACYAPPOWNEDWORKFLOWBOUNDARIESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_app_owned_workflow_boundaries(testCase) + setupLabKitTestPath(); + legacy_test_app_owned_workflow_boundaries(); + end + end +end + +function legacy_test_app_owned_workflow_boundaries() %TEST_APP_OWNED_WORKFLOW_BOUNDARIES Verify app-local workflow ownership. root = testRepoRoot(); diff --git a/tests/suites/project/test_package_dependency_boundaries.m b/tests/integration/project/LegacyPackageDependencyBoundariesTest.m similarity index 87% rename from tests/suites/project/test_package_dependency_boundaries.m rename to tests/integration/project/LegacyPackageDependencyBoundariesTest.m index e2fbbe5..061eb1f 100644 --- a/tests/suites/project/test_package_dependency_boundaries.m +++ b/tests/integration/project/LegacyPackageDependencyBoundariesTest.m @@ -1,4 +1,15 @@ -function test_package_dependency_boundaries() +classdef LegacyPackageDependencyBoundariesTest < matlab.unittest.TestCase + %LEGACYPACKAGEDEPENDENCYBOUNDARIESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_package_dependency_boundaries(testCase) + setupLabKitTestPath(); + legacy_test_package_dependency_boundaries(); + end + end +end + +function legacy_test_package_dependency_boundaries() %TEST_PACKAGE_DEPENDENCY_BOUNDARIES Verify reusable package dependency rules. root = testRepoRoot(); diff --git a/tests/suites/project/test_package_public_surface.m b/tests/integration/project/LegacyPackagePublicSurfaceTest.m similarity index 93% rename from tests/suites/project/test_package_public_surface.m rename to tests/integration/project/LegacyPackagePublicSurfaceTest.m index bc08b14..ff684fe 100644 --- a/tests/suites/project/test_package_public_surface.m +++ b/tests/integration/project/LegacyPackagePublicSurfaceTest.m @@ -1,4 +1,15 @@ -function test_package_public_surface() +classdef LegacyPackagePublicSurfaceTest < matlab.unittest.TestCase + %LEGACYPACKAGEPUBLICSURFACETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_package_public_surface(testCase) + setupLabKitTestPath(); + legacy_test_package_public_surface(); + end + end +end + +function legacy_test_package_public_surface() %TEST_PACKAGE_PUBLIC_SURFACE Verify public and private package file surfaces. root = testRepoRoot(); diff --git a/tests/suites/project/test_sensitive_sample_hygiene.m b/tests/integration/project/LegacySensitiveSampleHygieneTest.m similarity index 89% rename from tests/suites/project/test_sensitive_sample_hygiene.m rename to tests/integration/project/LegacySensitiveSampleHygieneTest.m index 89e3d4d..ae65b84 100644 --- a/tests/suites/project/test_sensitive_sample_hygiene.m +++ b/tests/integration/project/LegacySensitiveSampleHygieneTest.m @@ -1,4 +1,15 @@ -function test_sensitive_sample_hygiene() +classdef LegacySensitiveSampleHygieneTest < matlab.unittest.TestCase + %LEGACYSENSITIVESAMPLEHYGIENETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_sensitive_sample_hygiene(testCase) + setupLabKitTestPath(); + legacy_test_sensitive_sample_hygiene(); + end + end +end + +function legacy_test_sensitive_sample_hygiene() %TEST_SENSITIVE_SAMPLE_HYGIENE Guard tracked text against local sample-data leaks. root = testRepoRoot(); diff --git a/tests/suites/project/test_startup_boundaries.m b/tests/integration/project/LegacyStartupBoundariesTest.m similarity index 88% rename from tests/suites/project/test_startup_boundaries.m rename to tests/integration/project/LegacyStartupBoundariesTest.m index 2e7d376..afdf713 100644 --- a/tests/suites/project/test_startup_boundaries.m +++ b/tests/integration/project/LegacyStartupBoundariesTest.m @@ -1,4 +1,15 @@ -function test_startup_boundaries() +classdef LegacyStartupBoundariesTest < matlab.unittest.TestCase + %LEGACYSTARTUPBOUNDARIESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Integration', 'Style'}) + function test_startup_boundaries(testCase) + setupLabKitTestPath(); + legacy_test_startup_boundaries(); + end + end +end + +function legacy_test_startup_boundaries() %TEST_STARTUP_BOUNDARIES Check startup path and root entrypoint boundaries. root = testRepoRoot(); diff --git a/tests/integration/project/ProjectDebtGuardrailTest.m b/tests/integration/project/ProjectDebtGuardrailTest.m new file mode 100644 index 0000000..1ff8c64 --- /dev/null +++ b/tests/integration/project/ProjectDebtGuardrailTest.m @@ -0,0 +1,130 @@ +classdef ProjectDebtGuardrailTest < matlab.unittest.TestCase + %PROJECTDEBTGUARDRAILTEST Guardrails for legacy surfaces and expected debt. + + methods (Test, TestTags = {'Integration', 'Style'}) + function legacyTestBackdoorDebtDoesNotGrow(testCase) + root = setupLabKitTestPath(); + + testCommandFiles = uniqueMatchedFiles(root, {'apps', '+labkit'}, ... + '__labkit_test__'); + testCase.verifyEmpty(testCommandFiles, ... + ['legacy app test command references must not remain after Phase 4. Files: ' ... + strjoin(cellstr(testCommandFiles), ', ')]); + + handlerFiles = uniqueMatchedFiles(root, {'apps'}, ... + 'function\s+handlers\s*=\s*\w*[Aa]ppTestHandlers'); + testCase.verifyEmpty(handlerFiles, ... + ['legacy app test handler functions must not remain after Phase 4. Files: ' ... + strjoin(cellstr(handlerFiles), ', ')]); + + diagnosticsFiles = uniqueMatchedFiles(root, {'apps'}, ... + 'loadFileDiagnostics|parse\w*LoadDiagnosticsRequest|collectLoadDiagnostics'); + testCase.verifyEmpty(diagnosticsFiles, ... + ['hidden load diagnostics must not remain after Phase 4. Files: ' ... + strjoin(cellstr(diagnosticsFiles), ', ')]); + + fprintf('Legacy backdoor inventory: %d test-command files, %d handler files, %d diagnostics files.\n', ... + numel(testCommandFiles), numel(handlerFiles), numel(diagnosticsFiles)); + end + + function oversizedAppEntrypointDebtIsRemoved(testCase) + root = setupLabKitTestPath(); + actual = collectOversizedEntrypoints(root, 500); + testCase.verifyEmpty(actual, ... + ['app entrypoints must remain at or below 500 lines after Phase 5. Files: ' ... + strjoin(cellstr(actual), ', ')]); + fprintf('Entrypoint size debt inventory: %d files over 500 lines.\n', numel(actual)); + end + + function oldRunnerDependenciesAreRemoved(testCase) + root = setupLabKitTestPath(); + + testCase.verifyFalse(isfolder(fullfile(root, 'tests', 'suites')), ... + 'tests/suites must not remain after Phase 6 official-test migration.'); + testCase.verifyFalse(isfile(fullfile(root, 'tests', 'run_all_tests.m')), ... + 'tests/run_all_tests.m must not remain after Phase 6 official-test migration.'); + + dependencyFiles = uniqueMatchedFiles(root, ... + {'.github', 'scripts', 'docs', 'tests', 'buildfile.m', ... + 'README.md', 'AGENTS.md', 'apps', '+labkit'}, ... + 'IncludeLegacy|run_all_tests|tests[/\\]suites'); + dependencyFiles = setdiff(dependencyFiles, ... + "tests/integration/project/ProjectDebtGuardrailTest.m"); + testCase.verifyEmpty(dependencyFiles, ... + ['old custom-runner dependencies must not remain after Phase 6. Files: ' ... + strjoin(cellstr(dependencyFiles), ', ')]); + + fprintf('Old runner dependency inventory: %d files.\n', numel(dependencyFiles)); + end + end +end + +function files = uniqueMatchedFiles(root, scopes, pattern) + files = strings(1, 0); + for s = 1:numel(scopes) + scopeRoot = fullfile(root, scopes{s}); + if isfile(scopeRoot) + textFiles = {scopeRoot}; + elseif isfolder(scopeRoot) + textFiles = collectTextFiles(scopeRoot); + else + continue; + end + for k = 1:numel(textFiles) + content = fileread(textFiles{k}); + if ~isempty(regexp(content, pattern, 'once')) + files(end+1) = string(relativePath(root, textFiles{k})); %#ok + end + end + end + files = unique(files); +end + +function files = collectTextFiles(folder) + files = {}; + entries = dir(folder); + [~, order] = sort({entries.name}); + entries = entries(order); + for k = 1:numel(entries) + entry = entries(k); + if entry.isdir + if any(strcmp(entry.name, {'.', '..'})) + continue; + end + files = [files, collectTextFiles(fullfile(folder, entry.name))]; %#ok + elseif endsWith(entry.name, {'.m', '.md', '.ps1', '.sh', '.yml', '.yaml'}) + files{end+1} = fullfile(entry.folder, entry.name); %#ok + end + end +end + +function assertExpectedDebt(testCase, actualFiles, expectedMax, label) + testCase.verifyTrue(numel(actualFiles) <= expectedMax, ... + sprintf('%s. Current count %d exceeds expected debt %d. Files: %s', ... + label, numel(actualFiles), expectedMax, strjoin(cellstr(actualFiles), ', '))); +end + +function actual = collectOversizedEntrypoints(root, maxLines) + appFiles = dir(fullfile(root, 'apps', '**', 'labkit_*_app.m')); + actual = strings(1, 0); + for k = 1:numel(appFiles) + filepath = fullfile(appFiles(k).folder, appFiles(k).name); + lineCount = countFileLines(filepath); + if lineCount > maxLines + actual(end+1) = string(relativePath(root, filepath)); %#ok + end + end +end + +function n = countFileLines(filepath) + n = numel(readlines(filepath)); +end + +function rel = relativePath(root, filepath) + rel = filepath; + prefix = [root filesep]; + if startsWith(filepath, prefix) + rel = filepath(numel(prefix)+1:end); + end + rel = strrep(rel, filesep, '/'); +end diff --git a/tests/integration/project/ProjectDocumentationGuardrailTest.m b/tests/integration/project/ProjectDocumentationGuardrailTest.m new file mode 100644 index 0000000..cd45988 --- /dev/null +++ b/tests/integration/project/ProjectDocumentationGuardrailTest.m @@ -0,0 +1,157 @@ +classdef ProjectDocumentationGuardrailTest < matlab.unittest.TestCase + %PROJECTDOCUMENTATIONGUARDRAILTEST Public/private helper comment checks. + + methods (Test, TestTags = {'Integration', 'Style'}) + function publicLibraryFunctionsDocumentAppFacingContracts(testCase) + root = setupLabKitTestPath(); + publicFiles = collectPublicLibraryFiles(root); + missing = strings(1, 0); + for k = 1:numel(publicFiles) + if ~hasFunctionContractComment(publicFiles(k)) + missing(end+1) = string(relativePath(root, publicFiles(k))); %#ok + end + end + + testCase.verifyTrue(isempty(missing), ... + ['Public +labkit functions need app-facing contract comments immediately ' ... + 'after the function declaration: ' strjoin(cellstr(missing), ', ')]); + end + + function privateHelperContractDebtDoesNotGrow(testCase) + root = setupLabKitTestPath(); + expectedDebt = struct( ... + 'folder', { ... + '+labkit/+biosignal/private', ... + '+labkit/+dta/private', ... + '+labkit/+ui/+app/private', ... + '+labkit/+ui/+tool/private', ... + '+labkit/+ui/+view/private', ... + 'apps/image_measurement/curvature/private', ... + 'apps/image_measurement/focus_stack/private'}, ... + 'missingCount', {15, 20, 4, 11, 23, 9, 11}); + + actual = collectPrivateContractDebt(root); + expectedFolders = sort(string({expectedDebt.folder})); + actualFolders = sort(string({actual.folder})); + unexpectedFolders = setdiff(actualFolders, expectedFolders); + testCase.verifyTrue(isempty(unexpectedFolders), ... + ['expected-debt: new private-helper folders without implementation contracts: ' ... + strjoin(cellstr(unexpectedFolders), ', ')]); + + for k = 1:numel(expectedDebt) + folder = expectedDebt(k).folder; + idx = find(actualFolders == string(folder), 1); + actualCount = 0; + if ~isempty(idx) + actualCount = actual(idx).missingCount; + end + testCase.verifyTrue(actualCount <= expectedDebt(k).missingCount, ... + sprintf(['expected-debt: private helper implementation contract debt grew in %s. ' ... + 'Current %d, expected <= %d.'], folder, actualCount, expectedDebt(k).missingCount)); + end + + totalMissing = sum([actual.missingCount]); + fprintf('Private helper contract debt inventory: %d files missing top-of-file contracts.\n', ... + totalMissing); + end + end +end + +function files = collectPublicLibraryFiles(root) + allFiles = dir(fullfile(root, '+labkit', '**', '*.m')); + files = strings(1, 0); + for k = 1:numel(allFiles) + filepath = fullfile(allFiles(k).folder, allFiles(k).name); + if ~contains(filepath, [filesep 'private' filesep]) + files(end+1) = string(filepath); %#ok + end + end +end + +function tf = hasFunctionContractComment(filepath) + lines = readlines(filepath); + idx = find(startsWith(strtrim(lines), "function "), 1); + if isempty(idx) + tf = false; + return; + end + nextIdx = idx + 1; + while nextIdx <= numel(lines) && strlength(strtrim(lines(nextIdx))) == 0 + nextIdx = nextIdx + 1; + end + tf = nextIdx <= numel(lines) && startsWith(strtrim(lines(nextIdx)), "%"); +end + +function actual = collectPrivateContractDebt(root) + privateDirs = [ ... + collectPrivateDirs(fullfile(root, '+labkit')), ... + collectPrivateDirs(fullfile(root, 'apps'))]; + actual = struct('folder', {}, 'missingCount', {}); + for k = 1:numel(privateDirs) + folder = privateDirs(k); + if ~isTrackedPrivateScope(root, folder) + continue; + end + files = dir(fullfile(char(folder), '*.m')); + missing = 0; + for f = 1:numel(files) + filepath = fullfile(files(f).folder, files(f).name); + if ~hasTopFileContract(filepath) + missing = missing + 1; + end + end + if missing > 0 + actual(end+1) = struct( ... %#ok + 'folder', relativePath(root, folder), ... + 'missingCount', missing); + end + end +end + +function folders = collectPrivateDirs(folder) + folders = strings(1, 0); + if ~isfolder(folder) + return; + end + entries = dir(folder); + [~, order] = sort({entries.name}); + entries = entries(order); + for k = 1:numel(entries) + entry = entries(k); + if ~entry.isdir || any(strcmp(entry.name, {'.', '..'})) + continue; + end + child = fullfile(entry.folder, entry.name); + if strcmp(entry.name, 'private') + folders(end+1) = string(child); %#ok + else + folders = [folders, collectPrivateDirs(child)]; %#ok + end + end +end + +function tf = isTrackedPrivateScope(root, folder) + rel = string(relativePath(root, folder)); + tf = startsWith(rel, "+labkit/") || startsWith(rel, "apps/"); +end + +function tf = hasTopFileContract(filepath) + lines = readlines(filepath); + first = strings(0); + for k = 1:numel(lines) + if strlength(strtrim(lines(k))) > 0 + first = strtrim(lines(k)); + break; + end + end + tf = ~isempty(first) && startsWith(first, "%"); +end + +function rel = relativePath(root, filepath) + rel = char(filepath); + prefix = [root filesep]; + if startsWith(rel, prefix) + rel = rel(numel(prefix)+1:end); + end + rel = strrep(rel, filesep, '/'); +end diff --git a/tests/integration/project/ProjectStructureGuardrailTest.m b/tests/integration/project/ProjectStructureGuardrailTest.m new file mode 100644 index 0000000..8d24a9c --- /dev/null +++ b/tests/integration/project/ProjectStructureGuardrailTest.m @@ -0,0 +1,312 @@ +classdef ProjectStructureGuardrailTest < matlab.unittest.TestCase + %PROJECTSTRUCTUREGUARDRAILTEST Official project boundary guardrails. + + methods (Test, TestTags = {'Integration', 'Style'}) + function publicPackageSurfaceMatchesDocumentedFacades(testCase) + root = setupLabKitTestPath(); + h = architectureTestHelpers(); + + testCase.verifyFalse(isfolder(fullfile(root, '+labkit', '+app')), ... + 'Reusable +labkit should not keep the transitional +app package.'); + testCase.verifyFalse(isfolder(fullfile(root, '+labkit', '+plot')), ... + 'Reusable +labkit should not keep a plot package for app-specific plotting.'); + h.assertNoPackageMFiles(fullfile(root, '+labkit', '+analysis'), ... + 'Public reusable +labkit analysis'); + h.assertNoPackageMFiles(fullfile(root, '+labkit', '+data'), ... + 'Public reusable +labkit data'); + h.assertNoPackageMFiles(fullfile(root, '+labkit', '+io'), ... + 'Public reusable +labkit IO'); + h.assertNoPackageMFiles(fullfile(root, '+labkit', '+util'), ... + 'Reusable +labkit utility'); + + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui'), {}, ... + 'Layered +labkit UI root'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+app'), ... + {'createShell.m', 'dispatchRequest.m', 'runBusy.m', 'tab.m'}, ... + 'UI app facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+diag'), ... + {'createContext.m'}, ... + 'UI diagnostics facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+view'), ... + {'axes.m', 'draw.m', 'form.m', 'panel.m', 'place.m', ... + 'section.m', 'update.m'}, ... + 'UI view facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+ui', '+tool'), ... + {'anchorEditor.m', 'createRuntime.m', 'scaleBar.m', ... + 'scaleBarCalibration.m'}, ... + 'UI tool facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+dta'), ... + {'addFilesToSession.m', 'detectPulses.m', 'detectType.m', 'findFiles.m', ... + 'getColumn.m', 'getCurveXY.m', 'getMainCurve.m', 'getZCurve.m', ... + 'loadFile.m', 'loadFiles.m', 'loadFolder.m', 'loadSession.m', ... + 'makeSession.m', 'removeSelectedItemsFromSession.m', ... + 'saveSession.m', 'selectSessionItems.m'}, ... + 'Public reusable +labkit DTA facade'); + h.assertTopLevelMFiles(fullfile(root, '+labkit', '+biosignal'), ... + {'buildTemplate.m', 'compareGroups.m', 'cropSignal.m', ... + 'defaultEcgPeakOptions.m', 'detectEcgPeaks.m', 'filterSignal.m', ... + 'getChannel.m', 'listChannels.m', 'measureSegments.m', ... + 'readRecording.m', 'segmentByEvents.m'}, ... + 'Public reusable +labkit biosignal facade'); + end + + function packageDependencyBoundariesStayDomainNeutral(testCase) + root = setupLabKitTestPath(); + h = architectureTestHelpers(); + guiWords = h.guiWords(); + appWords = h.appEntrypointWords(); + workflowWords = h.experimentWorkflowWords(); + + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+dta'), ... + [guiWords {'apps/', 'labkit.io', 'labkit.data'} appWords], ... + 'Reusable +labkit DTA facade'); + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+dta', 'private'), ... + [guiWords {'labkit.ui', 'apps/'} appWords workflowWords], ... + 'DTA private implementation'); + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+biosignal'), ... + [guiWords {'apps/', 'labkit.ui', 'labkit.dta'} appWords], ... + 'Reusable +labkit biosignal facade'); + h.assertPackageSourcesDoNotContain(fullfile(root, '+labkit', '+biosignal', 'private'), ... + [guiWords {'apps/', 'labkit.ui', 'labkit.dta'} appWords], ... + 'Biosignal private implementation'); + + uiForbidden = [{'DTA', 'Gamry', 'labkit.dta', 'labkit.io', ... + 'labkit.data', 'labkit.analysis', 'apps/'} appWords]; + uiRoots = { ... + fullfile(root, '+labkit', '+ui'), ... + fullfile(root, '+labkit', '+ui', '+app'), ... + fullfile(root, '+labkit', '+ui', '+app', 'private'), ... + fullfile(root, '+labkit', '+ui', '+view'), ... + fullfile(root, '+labkit', '+ui', '+view', 'private'), ... + fullfile(root, '+labkit', '+ui', '+tool'), ... + fullfile(root, '+labkit', '+ui', '+tool', 'private'), ... + fullfile(root, '+labkit', '+ui', '+diag')}; + for k = 1:numel(uiRoots) + h.assertPackageSourcesDoNotContain(uiRoots{k}, uiForbidden, ... + ['Reusable UI boundary at ' relativePath(root, uiRoots{k})]); + end + + testCase.verifyFalse(isfile(fullfile(root, '+labkit', '+ui', 'loadFilesIntoSession.m')), ... + 'GUI-free session loading should live in +labkit/+dta, not +ui.'); + testCase.verifyFalse(isfile(fullfile(root, '+labkit', '+io', 'exportTableCSV.m')), ... + 'One-line CSV writer wrappers should not live in reusable +labkit.'); + end + + function appEntrypointsStayInOwningFolders(testCase) + root = setupLabKitTestPath(); + h = architectureTestHelpers(); + + testCase.verifyFalse(isfolder(fullfile(root, 'apps', 'private')), ... + 'The transitional apps/private launcher directory should be removed.'); + expectedDirs = {'electrochem', 'dic', 'image_measurement', 'wearable'}; + for k = 1:numel(expectedDirs) + testCase.verifyTrue(isfolder(fullfile(root, 'apps', expectedDirs{k})), ... + ['Missing app family folder: apps/' expectedDirs{k}]); + end + + entries = appEntryManifest(); + for k = 1:size(entries, 1) + appName = entries{k, 1}; + legacy = legacyEntrypointInfo(appName); + source = h.assertAppEntrypoint(root, appName, legacy.launchName, legacy.legacyCall); + assertAppFamilyBoundary(h, source, appName); + end + end + + function appOwnedWorkflowDoesNotLeakToReusablePackages(testCase) + root = setupLabKitTestPath(); + forbiddenPackages = { ... + fullfile(root, '+labkit', '+analysis'), ... + fullfile(root, '+labkit', '+data'), ... + fullfile(root, '+labkit', '+io'), ... + fullfile(root, '+labkit', '+util'), ... + fullfile(root, '+labkit', '+dic'), ... + fullfile(root, '+labkit', '+image_measurement'), ... + fullfile(root, '+labkit', '+ecg'), ... + fullfile(root, '+labkit', '+ui', '+control'), ... + fullfile(root, 'apps', '+labkit_apps')}; + for k = 1:numel(forbiddenPackages) + testCase.verifyFalse(isfolder(forbiddenPackages{k}), ... + ['Helper-dump or app-specific public package must not exist: ' ... + relativePath(root, forbiddenPackages{k})]); + end + end + + function sensitiveSampleHygieneScansTrackedText(testCase) + root = setupLabKitTestPath(); + files = collectTrackedTextScope(root); + testCase.assertFalse(isempty(files), ... + 'Sensitive sample hygiene should scan tracked text files.'); + + for k = 1:numel(files) + filepath = files{k}; + content = fileread(filepath); + rel = relativePath(root, filepath); + assertNoDriveRootPath(content, rel); + assertNoCurrentHomePath(content, rel); + assertNoSampleTimestampToken(content, rel); + end + end + + function startupPathKeepsPrivateHelpersPrivate(testCase) + root = setupLabKitTestPath(); + + testCase.verifyTrue(isfile(fullfile(root, 'startup_labkit.m')), ... + 'startup_labkit.m is missing.'); + testCase.verifyFalse(isfolder(fullfile(root, 'legacy')), ... + 'legacy/ should not be reintroduced.'); + testCase.verifyTrue(pathContains(fullfile(root, 'apps')), ... + 'startup_labkit should add apps/ to the path.'); + testCase.verifyTrue(pathContains(fullfile(root, 'apps', 'electrochem')), ... + 'startup_labkit should add nested app category folders to the path.'); + testCase.verifyTrue(pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature')), ... + 'startup_labkit should add nested image measurement app folders.'); + testCase.verifyFalse(pathContains(fullfile(root, 'apps', 'image_measurement', 'curvature', 'private')), ... + 'startup_labkit should not expose app-private helper folders.'); + end + end +end + +function assertAppFamilyBoundary(h, source, appName) + if contains(appName, 'ChronoOverlay') + h.assertDTAFacadeUsage(source, appName, 'chrono', true); + elseif contains(appName, 'EIS') + h.assertDTAFacadeUsage(source, appName, 'eis', true); + elseif contains(appName, 'CSC') + h.assertDTAFacadeUsage(source, appName, 'cvct', false); + elseif contains(appName, 'VTResistance') || contains(appName, 'CIC') + h.assertDTAFacadeUsage(source, appName, 'chrono', true); + elseif contains(appName, 'DIC') + h.assertDICAppBoundary(source, appName); + elseif contains(appName, 'CurvatureMeasurement') || contains(appName, 'FocusStack') + h.assertImageMeasurementAppBoundary(source, appName); + elseif contains(appName, 'ECGPrint') + h.assertWearableAppBoundary(source, appName); + end +end + +function legacy = legacyEntrypointInfo(appName) + switch appName + case 'labkit_ChronoOverlay_app' + legacy = struct('launchName', 'launchChronoOverlayApp', ... + 'legacyCall', 'gamry_multiDTA_plot_export_gui('); + case 'labkit_EIS_app' + legacy = struct('launchName', 'launchEISApp', ... + 'legacyCall', 'gamry_EIS_multiDTA_plot_gui('); + case 'labkit_CSC_app' + legacy = struct('launchName', 'launchCSCApp', ... + 'legacyCall', 'gamry_CV_CSC_dta_gui('); + case 'labkit_VTResistance_app' + legacy = struct('launchName', 'launchVTResistanceApp', ... + 'legacyCall', 'gamry_VT_resistance_gui('); + case 'labkit_CIC_app' + legacy = struct('launchName', 'launchCICApp', ... + 'legacyCall', 'gamry_CIC_VT_gui_paperlabels('); + case 'labkit_DICPreprocess_app' + legacy = struct('launchName', 'launchDICPreprocessApp', ... + 'legacyCall', 'dic_preprocess_gui('); + case 'labkit_DICPostprocess_app' + legacy = struct('launchName', 'launchDICPostprocessApp', ... + 'legacyCall', 'dic_postprocess_gui('); + case 'labkit_CurvatureMeasurement_app' + legacy = struct('launchName', 'launchCurvatureMeasurementApp', ... + 'legacyCall', 'curvature_measurement_gui('); + case 'labkit_FocusStack_app' + legacy = struct('launchName', 'launchFocusStackApp', ... + 'legacyCall', 'focus_stack_gui('); + case 'labkit_ECGPrint_app' + legacy = struct('launchName', 'launchECGPrintApp', ... + 'legacyCall', 'wearable_ecg_print_gui('); + otherwise + error('Unknown app entrypoint in manifest: %s', appName); + end +end + +function files = collectTrackedTextScope(root) + entries = {'README.md', 'AGENTS.md', 'docs', 'scripts', ... + 'tests', 'apps', '+labkit', '.github'}; + files = {}; + for k = 1:numel(entries) + path = fullfile(root, entries{k}); + if isfolder(path) + files = [files, collectTextFiles(path)]; %#ok + elseif isfile(path) && isTextFile(path) + files{end+1} = path; %#ok + end + end +end + +function files = collectTextFiles(folder) + files = {}; + entries = dir(folder); + [~, order] = sort({entries.name}); + entries = entries(order); + for k = 1:numel(entries) + entry = entries(k); + if entry.isdir + if any(strcmp(entry.name, {'.', '..'})) + continue; + end + files = [files, collectTextFiles(fullfile(folder, entry.name))]; %#ok + else + filepath = fullfile(folder, entry.name); + if isTextFile(filepath) + files{end+1} = filepath; %#ok + end + end + end +end + +function tf = isTextFile(filepath) + [~, ~, ext] = fileparts(filepath); + tf = any(strcmpi(ext, {'.m', '.md', '.ps1', '.sh', '.yml', '.yaml', ... + '.json', '.txt', '.csv', '.tsv'})); +end + +function assertNoDriveRootPath(content, rel) + matchStarts = regexp(content, '[A-Za-z]:[\\/]', 'start'); + isDriveRoot = false(size(matchStarts)); + for k = 1:numel(matchStarts) + isDriveRoot(k) = matchStarts(k) == 1 || ... + ~isstrprop(content(matchStarts(k)-1), 'alpha'); + end + assert(~any(isDriveRoot), ... + ['Tracked text file %s contains a drive-root absolute path. ' ... + 'Use synthetic relative paths in source, tests, and docs.'], rel); +end + +function assertNoCurrentHomePath(content, rel) + homeValues = unique(string({getenv('USERPROFILE'), getenv('HOME')})); + for k = 1:numel(homeValues) + home = homeValues(k); + if strlength(home) <= 3 + continue; + end + variants = unique([home, replace(home, "\", "/"), replace(home, "/", "\")]); + for i = 1:numel(variants) + assert(~contains(content, variants(i)), ... + ['Tracked text file %s contains the current user home path. ' ... + 'Use synthetic relative paths in source, tests, and docs.'], rel); + end + end +end + +function assertNoSampleTimestampToken(content, rel) + assert(isempty(regexp(content, '\d{8}_\d{6}', 'once')), ... + ['Tracked text file %s contains a timestamp-shaped sample token. ' ... + 'Use synthetic fixture names and metadata in source, tests, and docs.'], rel); +end + +function tf = pathContains(folder) + paths = strsplit(path, pathsep); + tf = any(strcmp(paths, folder)); +end + +function rel = relativePath(root, filepath) + rel = filepath; + prefix = [root filesep]; + if startsWith(filepath, prefix) + rel = filepath(numel(prefix)+1:end); + end + rel = strrep(rel, filesep, '/'); +end diff --git a/tests/runLabKitTests.m b/tests/runLabKitTests.m new file mode 100644 index 0000000..e22de7a --- /dev/null +++ b/tests/runLabKitTests.m @@ -0,0 +1,294 @@ +function output = runLabKitTests(varargin) +%RUNLABKITTESTS Run LabKit tests through MATLAB's official test framework. +% +% output = runLabKitTests(Name,Value) discovers official matlab.unittest +% tests under tests/unit, tests/integration, and tests/gui. +% +% Name-value options: +% IncludeGui Include tests under tests/gui. +% Suites Suite targets such as project, labkit/dta, or gui. +% Tests Test names or substrings to include. +% Tags Required official test tags. Multiple tags are ORed. +% ExcludeTags Official test tags to exclude. +% IncludeCoverage Generate Cobertura and HTML coverage artifacts. +% FailIfNoTests Error when no official tests match. +% ArtifactsRoot Root artifact directory. +% RunName Name used in artifact titles and console output. + + root = fileparts(fileparts(mfilename("fullpath"))); + addpath(fullfile(root, "tests", "support")); + setupLabKitTestPath(); + + opts = parseOptions(root, varargin{:}); + paths = labkitArtifactPaths("Root", opts.ArtifactsRoot, "Create", true); + suite = discoverOfficialSuite(root, opts); + + fprintf("LabKit official test run: %s\n", opts.RunName); + fprintf("Official tests matched: %d\n", numel(suite)); + + if isempty(suite) && opts.FailIfNoTests + error("LabKit:Tests:NoOfficialTests", ... + "No official matlab.unittest tests matched the requested selection."); + end + + runner = matlab.unittest.TestRunner.withTextOutput( ... + "OutputDetail", opts.OutputDetail, ... + "LoggingLevel", opts.LoggingLevel); + runner.addPlugin(matlab.unittest.plugins.XMLPlugin.producingJUnitFormat( ... + paths.junitXml)); + runner.addPlugin(matlab.unittest.plugins.TestReportPlugin.producingHTML( ... + paths.testHtml, ... + "MainFile", "index.html", ... + "Title", "LabKit MATLAB Tests - " + opts.RunName, ... + "IncludingCommandWindowText", true)); + + if opts.IncludeCoverage + coverageFormats = [ ... + matlab.unittest.plugins.codecoverage.CoverageReport( ... + paths.coverageHtml, "MainFile", "index.html"), ... + matlab.unittest.plugins.codecoverage.CoberturaFormat(paths.coberturaXml)]; + coverageFolders = { ... + char(fullfile(root, "+labkit")), ... + char(fullfile(root, "apps"))}; + runner.addPlugin(matlab.unittest.plugins.CodeCoveragePlugin.forFolder( ... + coverageFolders, ... + "IncludingSubfolders", true, ... + "Producing", coverageFormats)); + end + + officialResults = runner.run(suite); + if ~isempty(officialResults) && ~all([officialResults.Passed]) + error("LabKit:Tests:OfficialFailure", ... + "One or more official matlab.unittest tests failed."); + end + + output = struct( ... + "official", officialResults, ... + "artifacts", paths, ... + "runName", opts.RunName); +end + +function opts = parseOptions(root, varargin) + p = inputParser; + p.FunctionName = "runLabKitTests"; + p.addParameter("IncludeGui", false, @isLogicalScalar); + p.addParameter("Suites", strings(1, 0), @isStringLikeList); + p.addParameter("Tests", strings(1, 0), @isStringLikeList); + p.addParameter("Tags", strings(1, 0), @isStringLikeList); + p.addParameter("ExcludeTags", strings(1, 0), @isStringLikeList); + p.addParameter("IncludeCoverage", false, @isLogicalScalar); + p.addParameter("FailIfNoTests", true, @isLogicalScalar); + p.addParameter("ArtifactsRoot", fullfile(root, "artifacts"), @isTextScalar); + p.addParameter("RunName", "local", @isTextScalar); + p.addParameter("OutputDetail", "Concise", @isTextScalar); + p.addParameter("LoggingLevel", "Concise", @isTextScalar); + p.parse(varargin{:}); + + opts = p.Results; + opts.IncludeGui = logical(opts.IncludeGui); + opts.IncludeCoverage = logical(opts.IncludeCoverage); + opts.FailIfNoTests = logical(opts.FailIfNoTests); + opts.Suites = normalizeTextList(opts.Suites); + opts.Tests = normalizeTextList(opts.Tests); + opts.Tags = normalizeTextList(opts.Tags); + opts.ExcludeTags = normalizeTextList(opts.ExcludeTags); + opts.ArtifactsRoot = char(opts.ArtifactsRoot); + opts.RunName = string(opts.RunName); +end + +function suite = discoverOfficialSuite(root, opts) + testsRoot = fullfile(root, "tests"); + groups = discoverOfficialGroups(testsRoot); + groups = filterGroupsBySuite(groups, opts); + + suite = matlab.unittest.Test.empty(1, 0); + for k = 1:numel(groups) + suite = [suite, groups(k).suite]; %#ok + end + + suite = filterSuiteByName(suite, opts.Tests); + suite = filterSuiteByTags(suite, opts.Tags, opts.ExcludeTags); +end + +function groups = discoverOfficialGroups(testsRoot) + groups = struct("key", {}, "suite", {}); + roots = ["unit", "integration", "gui"]; + for r = 1:numel(roots) + sectionRoot = fullfile(testsRoot, roots(r)); + if exist(sectionRoot, "dir") ~= 7 + continue; + end + folders = foldersWithMFiles(sectionRoot); + for f = 1:numel(folders) + suite = matlab.unittest.TestSuite.fromFolder(folders(f), ... + "IncludingSubfolders", false, ... + "InvalidFileFoundAction", "warn"); + if isempty(suite) + continue; + end + key = relativeTestKey(folders(f), testsRoot); + groups(end+1) = struct("key", key, "suite", suite); %#ok + end + end +end + +function folders = foldersWithMFiles(root) + folders = strings(1, 0); + entries = dir(root); + [~, order] = sort({entries.name}); + entries = entries(order); + hasMFile = false; + for k = 1:numel(entries) + entry = entries(k); + if entry.isdir + if strcmp(entry.name, ".") || strcmp(entry.name, "..") + continue; + end + folders = [folders, foldersWithMFiles(fullfile(entry.folder, entry.name))]; %#ok + elseif endsWith(entry.name, ".m") + hasMFile = true; + end + end + if hasMFile + folders = [string(root), folders]; + end +end + +function groups = filterGroupsBySuite(groups, opts) + if isempty(groups) + return; + end + + suiteTargets = lower(normalizeSuiteTargets(opts.Suites)); + guiOnly = any(suiteTargets == "gui"); + suiteTargets(suiteTargets == "gui") = []; + + keep = true(size(groups)); + if ~opts.IncludeGui && ~guiOnly + keep = keep & ~startsWith([groups.key], "gui/"); + elseif guiOnly + keep = keep & startsWith([groups.key], "gui/"); + end + + if ~isempty(suiteTargets) + targetKeep = false(size(groups)); + for g = 1:numel(groups) + for t = 1:numel(suiteTargets) + targetKeep(g) = targetKeep(g) || groupMatchesSuite(groups(g).key, suiteTargets(t)); + end + end + keep = keep & targetKeep; + end + + groups = groups(keep); +end + +function suite = filterSuiteByName(suite, tests) + tests = lower(normalizeTextList(tests)); + if isempty(tests) || isempty(suite) + return; + end + + keep = false(size(suite)); + names = lower(string({suite.Name})); + for t = 1:numel(tests) + keep = keep | contains(names, tests(t)); + end + suite = suite(keep); +end + +function suite = filterSuiteByTags(suite, includeTags, excludeTags) + if isempty(suite) + return; + end + + includeTags = lower(normalizeTextList(includeTags)); + excludeTags = lower(normalizeTextList(excludeTags)); + + keep = true(size(suite)); + if ~isempty(includeTags) + keep = false(size(suite)); + for k = 1:numel(suite) + tags = lower(string(suite(k).Tags)); + keep(k) = any(ismember(tags, includeTags)); + end + end + + if ~isempty(excludeTags) + for k = 1:numel(suite) + tags = lower(string(suite(k).Tags)); + keep(k) = keep(k) && ~any(ismember(tags, excludeTags)); + end + end + + suite = suite(keep); +end + +function tf = groupMatchesSuite(groupKey, target) + candidates = unique([ ... + target, ... + "unit/" + target, ... + "integration/" + target, ... + "gui/structural/" + target, ... + "gui/gesture/" + target]); + if startsWith(target, "apps/") + family = eraseBetween(target, 1, strlength("apps/")); + candidates(end+1) = "integration/app_workflows/" + family; %#ok + end + + tf = false; + for k = 1:numel(candidates) + candidate = candidates(k); + tf = tf || groupKey == candidate || startsWith(groupKey, candidate + "/"); + end +end + +function key = relativeTestKey(folder, testsRoot) + key = extractAfter(string(folder), strlength(string(testsRoot)) + 1); + key = replace(key, filesep, "/"); + while startsWith(key, "/") + key = extractAfter(key, 1); + end +end + +function targets = normalizeSuiteTargets(targets) + targets = normalizeTextList(targets); + for k = 1:numel(targets) + target = replace(targets(k), "\", "/"); + target = erase(target, "tests/unit/"); + target = erase(target, "tests/integration/"); + while startsWith(target, "/") + target = extractAfter(target, 1); + end + while endsWith(target, "/") + target = extractBefore(target, strlength(target)); + end + targets(k) = target; + end +end + +function values = normalizeTextList(values) + if isempty(values) + values = strings(1, 0); + elseif ischar(values) + values = string({values}); + elseif iscell(values) + values = string(values); + else + values = string(values); + end + values = values(:).'; + values = values(strlength(values) > 0); +end + +function tf = isStringLikeList(value) + tf = ischar(value) || isstring(value) || iscellstr(value); +end + +function tf = isTextScalar(value) + tf = (ischar(value) || (isstring(value) && isscalar(value))); +end + +function tf = isLogicalScalar(value) + tf = (islogical(value) || isnumeric(value)) && isscalar(value); +end diff --git a/tests/run_all_tests.m b/tests/run_all_tests.m deleted file mode 100644 index 8690814..0000000 --- a/tests/run_all_tests.m +++ /dev/null @@ -1,256 +0,0 @@ -function results = run_all_tests(includeGui, selection) -%RUN_ALL_TESTS Run the current MATLAB test suite. -% -% Tests live under tests/suites//test_*.m. Targets mirror source -% ownership: project guardrails, labkit libraries, and app family folders. -% The runner discovers targets recursively and filters by directory name. - - if nargin < 1 - includeGui = false; - end - if nargin < 2 - selection = struct(); - end - - root = fileparts(fileparts(mfilename('fullpath'))); - testsRoot = fullfile(root, 'tests'); - addpath(root); - addpath(genpath(testsRoot)); - startup_labkit(); - - results = runLabkitTests(testsRoot, includeGui, selection); -end - -function results = runLabkitTests(testsRoot, includeGui, selection) - suiteRoot = fullfile(testsRoot, 'suites'); - groups = discoverTestGroups(suiteRoot); - assertUniqueTestNames(groups); - - [groups, guiOnly] = filterGroupsBySuite(groups, selection); - groups = filterTestsByGuiMode(groups, includeGui, guiOnly); - groups = filterGroupsByTests(groups, selection); - groups = removeEmptyGroups(groups); - assert(~isempty(groups), 'No tests matched the requested selection.'); - - results = struct('group', {}, 'name', {}, 'passed', {}, 'message', {}, 'duration_s', {}); - suiteStart = tic; - - for g = 1:numel(groups) - fprintf('\n[%s]\n', groups(g).key); - tests = groups(g).tests; - groupStart = tic; - for k = 1:numel(tests) - name = tests(k).name; - testStart = tic; - try - tests(k).handle(); - duration = toc(testStart); - results(end+1) = struct( ... - 'group', groups(g).key, ... - 'name', name, ... - 'passed', true, ... - 'message', '', ... - 'duration_s', duration); %#ok - fprintf('PASS %s (%.2fs)\n', name, duration); - catch ME - duration = toc(testStart); - results(end+1) = struct( ... - 'group', groups(g).key, ... - 'name', name, ... - 'passed', false, ... - 'message', ME.message, ... - 'duration_s', duration); %#ok - fprintf(2, 'FAIL %s (%.2fs): %s\n', name, duration, ME.message); - end - end - fprintf('[%s completed in %.2fs]\n', groups(g).key, toc(groupStart)); - end - - if any(~[results.passed]) - error('One or more tests failed.'); - end - - fprintf('\nAll selected tests passed in %.2fs.\n', toc(suiteStart)); -end - -function groups = discoverTestGroups(suiteRoot) - files = discoverTestFiles(suiteRoot, suiteRoot); - groups = struct('key', {}, 'tests', {}); - if isempty(files) - return; - end - - keys = unique({files.groupKey}); - for g = 1:numel(keys) - key = keys{g}; - groupFiles = files(strcmp({files.groupKey}, key)); - [~, order] = sort({groupFiles.name}); - groupFiles = groupFiles(order); - - tests = struct('name', {}, 'handle', {}, 'isGui', {}); - for k = 1:numel(groupFiles) - functionName = groupFiles(k).functionName; - tests(end+1) = struct( ... - 'name', functionName, ... - 'handle', str2func(functionName), ... - 'isGui', startsWith(functionName, 'test_gui_')); %#ok - end - groups(end+1) = struct('key', key, 'tests', {tests}); %#ok - end -end - -function files = discoverTestFiles(folder, suiteRoot) - files = struct('name', {}, 'functionName', {}, 'groupKey', {}); - entries = dir(folder); - [~, order] = sort({entries.name}); - entries = entries(order); - - for k = 1:numel(entries) - entry = entries(k); - if entry.isdir - if strcmp(entry.name, '.') || strcmp(entry.name, '..') - continue; - end - childFiles = discoverTestFiles(fullfile(folder, entry.name), suiteRoot); - files = [files, childFiles]; %#ok - elseif startsWith(entry.name, 'test_') && endsWith(entry.name, '.m') - [~, functionName] = fileparts(entry.name); - files(end+1) = struct( ... - 'name', entry.name, ... - 'functionName', functionName, ... - 'groupKey', suiteGroupKey(folder, suiteRoot)); %#ok - end - end -end - -function key = suiteGroupKey(folder, suiteRoot) - if strcmp(folder, suiteRoot) - key = '.'; - return; - end - key = folder(numel(suiteRoot) + 2:end); - key = strrep(key, filesep, '/'); -end - -function [groups, guiOnly] = filterGroupsBySuite(groups, selection) - suiteFilter = normalizedCellField(selection, 'suites'); - guiOnly = any(strcmp(suiteFilter, 'gui')); - suiteFilter(strcmp(suiteFilter, 'gui')) = []; - suiteFilter = normalizeSuiteTargets(suiteFilter); - - if isempty(suiteFilter) - return; - end - - keep = false(size(groups)); - for g = 1:numel(groups) - for k = 1:numel(suiteFilter) - keep(g) = keep(g) || groupMatchesTarget(groups(g).key, suiteFilter{k}); - end - end - groups = groups(keep); -end - -function targets = normalizeSuiteTargets(targets) - for k = 1:numel(targets) - targets{k} = normalizeSuiteTarget(targets{k}); - end -end - -function target = normalizeSuiteTarget(target) - target = strrep(target, '\', '/'); - prefix = 'tests/suites/'; - if startsWith(target, prefix) - target = target(numel(prefix) + 1:end); - end - while startsWith(target, '/') - target = target(2:end); - end - while endsWith(target, '/') - target = target(1:end-1); - end - -end - -function tf = groupMatchesTarget(groupKey, target) - tf = strcmp(groupKey, target) || startsWith(groupKey, [target '/']); -end - -function groups = filterTestsByGuiMode(groups, includeGui, guiOnly) - for g = 1:numel(groups) - tests = groups(g).tests; - if isempty(tests) - continue; - end - if guiOnly - groups(g).tests = tests([tests.isGui]); - elseif ~includeGui - groups(g).tests = tests(~[tests.isGui]); - end - end -end - -function groups = filterGroupsByTests(groups, selection) - testFilter = normalizedCellField(selection, 'tests'); - if isempty(testFilter) - return; - end - - matchedCount = 0; - for g = 1:numel(groups) - tests = groups(g).tests; - keepTest = false(size(tests)); - for k = 1:numel(tests) - keepTest(k) = any(strcmp(testFilter, lower(tests(k).name))); - end - groups(g).tests = tests(keepTest); - matchedCount = matchedCount + nnz(keepTest); - end - assert(matchedCount > 0, 'No tests matched the requested --test selection.'); -end - -function groups = removeEmptyGroups(groups) - keep = false(size(groups)); - for g = 1:numel(groups) - keep(g) = ~isempty(groups(g).tests); - end - groups = groups(keep); -end - -function values = normalizedCellField(s, fieldName) - values = {}; - if ~isfield(s, fieldName) - return; - end - - raw = s.(fieldName); - if isempty(raw) - return; - elseif ischar(raw) || isstring(raw) - values = cellstr(raw); - elseif iscell(raw) - values = raw; - else - error('Test selection field "%s" must be a string or cell array.', fieldName); - end - values = lower(string(values)); - values = cellstr(values(:).'); -end - -function assertUniqueTestNames(groups) - names = {}; - for g = 1:numel(groups) - for k = 1:numel(groups(g).tests) - names{end+1} = groups(g).tests(k).name; %#ok - end - end - [uniqueNames, ia] = unique(names); - if numel(uniqueNames) == numel(names) - return; - end - - duplicateMask = true(size(names)); - duplicateMask(ia) = false; - duplicateNames = unique(names(duplicateMask)); - error('Duplicate test function names discovered: %s.', strjoin(duplicateNames, ', ')); -end diff --git a/tests/support/createLabKitGuiFixture.m b/tests/support/createLabKitGuiFixture.m new file mode 100644 index 0000000..69440cf --- /dev/null +++ b/tests/support/createLabKitGuiFixture.m @@ -0,0 +1,41 @@ +function fixture = createLabKitGuiFixture(testCase) +%CREATELABKITGUIFIXTURE Return helpers for noninteractive GUI tests. +% +% Expected caller: matlab.uitest or matlab.unittest GUI tests. The optional +% testCase input receives figure cleanup teardowns. Full interactive workflow +% validation remains manual. + + if nargin < 1 + testCase = []; + end + + fixture = struct(); + fixture.assertUifigureAvailable = @assertUifigureAvailable; + fixture.closeFigure = @closeFigure; + fixture.addFigureTeardown = @addFigureTeardown; +end + +function assertUifigureAvailable() + try + fig = uifigure("Visible", "off"); + cleanup = onCleanup(@() closeFigure(fig)); + drawnow; + catch ME + error("LabKit:GUI:Unavailable", ... + "MATLAB uifigure support is unavailable: %s", ME.message); + end +end + +function addFigureTeardown(fig) + if isempty(testCase) + return; + end + testCase.addTeardown(@() closeFigure(fig)); +end + +function closeFigure(fig) + if ~isempty(fig) && isvalid(fig) + close(fig); + drawnow; + end +end diff --git a/tests/support/createLabKitToolTraceSink.m b/tests/support/createLabKitToolTraceSink.m new file mode 100644 index 0000000..baba8bb --- /dev/null +++ b/tests/support/createLabKitToolTraceSink.m @@ -0,0 +1,120 @@ +function sink = createLabKitToolTraceSink(recorder) +%CREATELABKITTOOLTRACESINK Adapt UI tool trace messages to structured events. +% +% Expected caller: GUI structural and gesture tests that pass an onTrace +% callback into labkit.ui.tool components. Input is a trace recorder from +% createLabKitTraceRecorder. Output is a callback(message) function handle. +% Side effects: appends sanitized structured events to the recorder. + + sink = @capture; + + function capture(message) + [component, detailMessage] = splitToolMessage(message); + [eventName, details] = classifyToolEvent(component, detailMessage); + recorder.record(component, eventName, "test", details); + end +end + +function [component, detailMessage] = splitToolMessage(message) + message = string(message); + parts = split(message, ":"); + if numel(parts) < 2 + component = "tool"; + detailMessage = strtrim(message); + return; + end + + rawComponent = strtrim(parts(1)); + detailMessage = strtrim(strjoin(parts(2:end), ":")); + switch rawComponent + case "imageAxesRuntime" + component = "runtime"; + case "anchorCurveEditor" + component = "anchorEditor"; + case "scaleBarTool" + component = "scaleBar"; + otherwise + component = rawComponent; + end +end + +function [eventName, details] = classifyToolEvent(component, message) + eventName = "trace"; + details = struct("message", message); + + if component == "runtime" + eventName = classifyRuntimeEvent(message); + elseif component == "anchorEditor" + eventName = classifyAnchorEvent(message); + elseif component == "scaleBar" + eventName = classifyScaleBarEvent(message); + end +end + +function eventName = classifyRuntimeEvent(message) + if startsWith(message, "activate session") + eventName = "session.activate"; + elseif startsWith(message, "deactivate session") + eventName = "session.deactivate"; + elseif startsWith(message, "deactivate peer") + eventName = "session.peerDeactivate"; + elseif startsWith(message, "capture drag") + eventName = "drag.capture"; + elseif startsWith(message, "release drag") + eventName = "drag.release"; + elseif startsWith(message, "drag motion error") + eventName = "drag.motionError"; + elseif startsWith(message, "drag release error") + eventName = "drag.releaseError"; + elseif startsWith(message, "installed session scroll") + eventName = "scroll.install"; + elseif contains(message, "default scroll") + eventName = "scroll.default"; + elseif startsWith(message, "delete runtime") + eventName = "runtime.delete"; + else + eventName = "trace"; + end +end + +function eventName = classifyAnchorEvent(message) + if startsWith(message, "start") + eventName = "edit.start"; + elseif startsWith(message, "setActive") + eventName = "active.set"; + elseif startsWith(message, "insertPoint") + eventName = "anchor.insert"; + elseif startsWith(message, "undoLast") + eventName = "anchor.undo"; + elseif startsWith(message, "clearPoints") + eventName = "anchor.clear"; + elseif startsWith(message, "notifyChanged") + eventName = "changed"; + elseif startsWith(message, "onAnchorDragged") + eventName = "drag.update"; + elseif startsWith(message, "onAnchorReleased") + eventName = "drag.release"; + elseif startsWith(message, "setStyle skipped unchanged") + eventName = "style.noop"; + else + eventName = "trace"; + end +end + +function eventName = classifyScaleBarEvent(message) + if startsWith(message, "Measure reference button starting edit") + eventName = "referenceEdit.start"; + elseif startsWith(message, "Measure reference button finishing active edit") + eventName = "referenceEdit.finish"; + elseif startsWith(message, "setEnabled") + eventName = "enabled.set"; + elseif startsWith(message, "setReferencePixels") + eventName = "referencePixels.set"; + elseif startsWith(message, "panel scale-bar settings changed") + eventName = "settings.change"; + elseif startsWith(message, "Place scale bar complete") + eventName = "scaleBar.place"; + else + eventName = "trace"; + end +end diff --git a/tests/support/createLabKitTraceRecorder.m b/tests/support/createLabKitTraceRecorder.m new file mode 100644 index 0000000..16aa560 --- /dev/null +++ b/tests/support/createLabKitTraceRecorder.m @@ -0,0 +1,134 @@ +function recorder = createLabKitTraceRecorder(varargin) +%CREATELABKITTRACERECORDER Create a structured diagnostic trace recorder. +% +% Expected caller: official tests and future GUI diagnostics. The returned +% struct exposes record, events, writeJsonl, and writeText function handles. +% Trace details are sanitized to avoid local paths and sensitive sample tokens. + + p = inputParser; + p.addParameter("AppName", "", @(v) ischar(v) || isstring(v)); + p.addParameter("TestName", "", @(v) ischar(v) || isstring(v)); + p.addParameter("RunId", "", @(v) ischar(v) || isstring(v)); + p.addParameter("SessionId", "", @(v) ischar(v) || isstring(v)); + p.addParameter("Level", "info", @(v) ischar(v) || isstring(v)); + p.parse(varargin{:}); + + state.events = struct([]); + state.seq = 0; + state.start = tic; + + defaults = p.Results; + if strlength(string(defaults.RunId)) == 0 + defaults.RunId = defaultRunId(); + end + + recorder = struct(); + recorder.record = @record; + recorder.events = @events; + recorder.writeJsonl = @writeJsonl; + recorder.writeText = @writeText; + recorder.runId = string(defaults.RunId); + + function eventRecord = record(component, eventName, reason, details, varargin) + if nargin < 4 + details = struct(); + end + local = parseRecordOptions(defaults, varargin{:}); + reason = validatestring(char(reason), ... + {'user', 'internal', 'programmatic', 'test'}); + state.seq = state.seq + 1; + eventRecord = struct( ... + "schemaVersion", 1, ... + "timestamp", string(datetime("now", "TimeZone", "UTC", ... + "Format", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")), ... + "elapsedMs", round(toc(state.start) * 1000, 3), ... + "seq", state.seq, ... + "runId", string(local.RunId), ... + "appName", string(local.AppName), ... + "testName", string(local.TestName), ... + "component", string(component), ... + "event", string(eventName), ... + "reason", string(reason), ... + "level", string(local.Level), ... + "sessionId", string(local.SessionId), ... + "details", sanitizeDetails(details)); + state.events = appendEvent(state.events, eventRecord); + end + + function value = events() + value = state.events; + end + + function writeJsonl(filepath) + writeLabKitJsonlArtifact(filepath, state.events); + end + + function writeText(filepath) + writeLabKitTextArtifact(filepath, renderLabKitTraceText(state.events)); + end +end + +function opts = parseRecordOptions(defaults, varargin) + p = inputParser; + p.addParameter("AppName", defaults.AppName, @(v) ischar(v) || isstring(v)); + p.addParameter("TestName", defaults.TestName, @(v) ischar(v) || isstring(v)); + p.addParameter("RunId", defaults.RunId, @(v) ischar(v) || isstring(v)); + p.addParameter("SessionId", defaults.SessionId, @(v) ischar(v) || isstring(v)); + p.addParameter("Level", defaults.Level, @(v) ischar(v) || isstring(v)); + p.parse(varargin{:}); + opts = p.Results; +end + +function events = appendEvent(events, eventRecord) + if isempty(events) + events = eventRecord; + else + events(end+1) = eventRecord; + end +end + +function runId = defaultRunId() + runId = "run-" + string(datetime("now", "Format", "yyyyMMdd'T'HHmmssSSS")) ... + + "-" + string(randi([100000, 999999])); +end + +function value = sanitizeDetails(value) + if isstruct(value) + fields = fieldnames(value); + for k = 1:numel(value) + for f = 1:numel(fields) + field = fields{f}; + if isSensitiveField(field) + value(k).(field) = "[redacted]"; + else + value(k).(field) = sanitizeDetails(value(k).(field)); + end + end + end + elseif iscell(value) + for k = 1:numel(value) + value{k} = sanitizeDetails(value{k}); + end + elseif ischar(value) || isstring(value) + value = sanitizeText(value); + end +end + +function tf = isSensitiveField(field) + field = lower(string(field)); + tokens = ["path", "filepath", "filename", "sourcefile", ... + "subject", "user", "device", "serial", "timestamp"]; + tf = any(contains(field, tokens)); +end + +function value = sanitizeText(value) + value = string(value); + values = cellstr(value); + driveRootPattern = "[A-Za-z]:[\\/]"; + homePathPattern = "(^|[^A-Za-z0-9])[/\\](Users|home)[/\\]"; + dateTokenPattern = "\d{4}[-_]\d{2}[-_]\d{2}"; + sensitive = ~cellfun(@isempty, regexp(values, driveRootPattern, "once")) ... + | ~cellfun(@isempty, regexp(values, homePathPattern, "once")) ... + | ~cellfun(@isempty, regexp(values, dateTokenPattern, "once")); + value(sensitive) = "[redacted]"; +end diff --git a/tests/support/labkitArtifactPaths.m b/tests/support/labkitArtifactPaths.m new file mode 100644 index 0000000..d332ff1 --- /dev/null +++ b/tests/support/labkitArtifactPaths.m @@ -0,0 +1,58 @@ +function paths = labkitArtifactPaths(varargin) +%LABKITARTIFACTPATHS Return standard LabKit test artifact paths. +% +% Expected caller: runners, tests, and GUI artifact helpers. Options: +% Root artifact root directory, default /artifacts +% Create logical flag that creates directories when true +% +% Output fields include JUnit XML, HTML test results, coverage, MATLAB log, +% GUI trace, and GUI snapshot locations. + + p = inputParser; + p.addParameter("Root", defaultArtifactRoot(), @(v) ischar(v) || isstring(v)); + p.addParameter("Create", false, @(v) islogical(v) || isnumeric(v)); + p.parse(varargin{:}); + + artifactRoot = char(p.Results.Root); + createDirs = logical(p.Results.Create); + + paths = struct(); + paths.root = artifactRoot; + paths.testResults = fullfile(artifactRoot, "test-results"); + paths.junitXml = fullfile(paths.testResults, "junit.xml"); + paths.testHtml = fullfile(paths.testResults, "html"); + paths.coverage = fullfile(artifactRoot, "coverage"); + paths.coberturaXml = fullfile(paths.coverage, "cobertura.xml"); + paths.coverageHtml = fullfile(paths.coverage, "html"); + paths.logs = fullfile(artifactRoot, "logs"); + paths.matlabLog = fullfile(paths.logs, "matlab.log"); + paths.gui = fullfile(artifactRoot, "gui"); + paths.guiTrace = fullfile(paths.gui, "trace"); + paths.guiSnapshots = fullfile(paths.gui, "snapshots"); + + if createDirs + ensureDirectory(paths.root); + ensureDirectory(paths.testResults); + ensureDirectory(paths.testHtml); + ensureDirectory(paths.coverage); + ensureDirectory(paths.coverageHtml); + ensureDirectory(paths.logs); + ensureDirectory(paths.guiTrace); + ensureDirectory(paths.guiSnapshots); + end +end + +function root = defaultArtifactRoot() + envRoot = getenv("LABKIT_ARTIFACTS"); + if strlength(string(envRoot)) > 0 + root = char(envRoot); + else + root = fullfile(labkitRepoRoot(), "artifacts"); + end +end + +function ensureDirectory(folder) + if exist(folder, "dir") ~= 7 + mkdir(folder); + end +end diff --git a/tests/support/labkitFixturePath.m b/tests/support/labkitFixturePath.m new file mode 100644 index 0000000..15abbcb --- /dev/null +++ b/tests/support/labkitFixturePath.m @@ -0,0 +1,9 @@ +function filepath = labkitFixturePath(varargin) +%LABKITFIXTUREPATH Return a path under tests/fixtures. +% +% Expected caller: official tests needing synthetic repository fixtures. +% Inputs: path segments below tests/fixtures. Output is an absolute path. + + root = labkitRepoRoot(); + filepath = fullfile(root, "tests", "fixtures", varargin{:}); +end diff --git a/tests/support/labkitRepoRoot.m b/tests/support/labkitRepoRoot.m new file mode 100644 index 0000000..4792d59 --- /dev/null +++ b/tests/support/labkitRepoRoot.m @@ -0,0 +1,8 @@ +function root = labkitRepoRoot() +%LABKITREPOROOT Return the LabKit repository root from test support code. +% +% Expected caller: tests, wrappers, and build/test support helpers. +% Output: absolute repository root path as a character vector. + + root = fileparts(fileparts(fileparts(mfilename("fullpath")))); +end diff --git a/tests/support/renderLabKitTraceText.m b/tests/support/renderLabKitTraceText.m new file mode 100644 index 0000000..d4c4890 --- /dev/null +++ b/tests/support/renderLabKitTraceText.m @@ -0,0 +1,30 @@ +function lines = renderLabKitTraceText(events) +%RENDERLABKITTRACETEXT Render structured trace events for human inspection. +% +% Expected caller: trace artifact writers. Input is a trace event struct array. +% Output is a string array with sanitized, stable diagnostic lines. + + if isempty(events) + lines = "no trace events"; + return; + end + + lines = strings(1, numel(events)); + for k = 1:numel(events) + details = ""; + if isfield(events(k), "details") && ~isempty(events(k).details) + details = " details=" + string(jsonencode(events(k).details)); + end + lines(k) = sprintf("%06d %8.3fms app=%s test=%s component=%s event=%s reason=%s level=%s session=%s%s", ... + events(k).seq, ... + events(k).elapsedMs, ... + char(events(k).appName), ... + char(events(k).testName), ... + char(events(k).component), ... + char(events(k).event), ... + char(events(k).reason), ... + char(events(k).level), ... + char(events(k).sessionId), ... + char(details)); + end +end diff --git a/tests/support/setupLabKitTestPath.m b/tests/support/setupLabKitTestPath.m new file mode 100644 index 0000000..ad26c5c --- /dev/null +++ b/tests/support/setupLabKitTestPath.m @@ -0,0 +1,14 @@ +function root = setupLabKitTestPath() +%SETUPLABKITTESTPATH Add repo and test support paths for official tests. +% +% Expected caller: tests/runLabKitTests.m and official matlab.unittest tests. +% Side effects: adds the repository root, tests, tests/support, and +% tests/helpers to the MATLAB path, then runs startup_labkit. + + root = labkitRepoRoot(); + addpath(root); + addpath(fullfile(root, "tests")); + addpath(fullfile(root, "tests", "support")); + addpath(fullfile(root, "tests", "helpers")); + startup_labkit(); +end diff --git a/tests/support/snapshotLabKitComponents.m b/tests/support/snapshotLabKitComponents.m new file mode 100644 index 0000000..f7508bc --- /dev/null +++ b/tests/support/snapshotLabKitComponents.m @@ -0,0 +1,59 @@ +function snapshot = snapshotLabKitComponents(rootHandle) +%SNAPSHOTLABKITCOMPONENTS Capture a sanitized component snapshot. +% +% Expected caller: GUI structural and gesture tests. Input is a figure, +% panel, axes, or UI component handle. Output is a struct array with generic +% component metadata only; file paths and sample details are not captured. + + handles = findall(rootHandle); + snapshot = struct("class", {}, "type", {}, "tag", {}, "text", {}, ... + "title", {}, "visible", {}, "enable", {}, "childCount", {}); + for k = 1:numel(handles) + h = handles(k); + if ~isvalid(h) + continue; + end + snapshot(end+1) = struct( ... %#ok + "class", string(class(h)), ... + "type", string(readProp(h, "Type")), ... + "tag", string(readProp(h, "Tag")), ... + "text", sanitizeText(readProp(h, "Text")), ... + "title", sanitizeText(readProp(h, "Title")), ... + "visible", string(readProp(h, "Visible")), ... + "enable", string(readProp(h, "Enable")), ... + "childCount", childCount(h)); + end +end + +function n = childCount(h) + try + n = numel(allchild(h)); + catch + n = 0; + end +end + +function value = readProp(h, propName) + if isprop(h, propName) + value = h.(propName); + else + value = ""; + end +end + +function value = sanitizeText(value) + if isobject(value) + if isprop(value, "String") + value = value.String; + else + value = class(value); + end + end + value = string(value); + values = cellstr(value); + driveRootPattern = "[A-Za-z]:[\\/]"; + homePathPattern = "(^|[^A-Za-z0-9])[/\\](Users|home)[/\\]"; + sensitive = ~cellfun(@isempty, regexp(values, driveRootPattern, "once")) ... + | ~cellfun(@isempty, regexp(values, homePathPattern, "once")); + value(sensitive) = "[redacted]"; +end diff --git a/tests/support/writeLabKitJsonlArtifact.m b/tests/support/writeLabKitJsonlArtifact.m new file mode 100644 index 0000000..887b7e5 --- /dev/null +++ b/tests/support/writeLabKitJsonlArtifact.m @@ -0,0 +1,29 @@ +function writeLabKitJsonlArtifact(filepath, records) +%WRITELABKITJSONLARTIFACT Write struct records as JSON Lines. +% +% Expected caller: structured trace and machine-readable GUI artifact code. +% Inputs: a file path and a struct array or cell array of JSON-encodable +% values. Side effects: creates the parent folder and overwrites the file. + + ensureParent(filepath); + fid = fopen(filepath, "w", "n", "UTF-8"); + assert(fid > 0, "Unable to open JSONL artifact for writing: %s", filepath); + cleanup = onCleanup(@() fclose(fid)); + + if iscell(records) + for k = 1:numel(records) + fprintf(fid, "%s\n", jsonencode(records{k})); + end + else + for k = 1:numel(records) + fprintf(fid, "%s\n", jsonencode(records(k))); + end + end +end + +function ensureParent(filepath) + parent = fileparts(filepath); + if ~isempty(parent) && exist(parent, "dir") ~= 7 + mkdir(parent); + end +end diff --git a/tests/support/writeLabKitTextArtifact.m b/tests/support/writeLabKitTextArtifact.m new file mode 100644 index 0000000..df937bc --- /dev/null +++ b/tests/support/writeLabKitTextArtifact.m @@ -0,0 +1,37 @@ +function writeLabKitTextArtifact(filepath, lines) +%WRITELABKITTEXTARTIFACT Write a UTF-8 text artifact. +% +% Expected caller: test runners and GUI artifact helpers. Inputs are an +% absolute or repo-local file path and a string, char, or cellstr line list. +% Side effects: creates the parent folder and overwrites the target file. + + ensureParent(filepath); + text = normalizeText(lines); + fid = fopen(filepath, "w", "n", "UTF-8"); + assert(fid > 0, "Unable to open artifact for writing: %s", filepath); + cleanup = onCleanup(@() fclose(fid)); + fprintf(fid, "%s", text); +end + +function text = normalizeText(lines) + if iscell(lines) + lines = string(lines); + end + if isstring(lines) + text = strjoin(lines(:), newline); + else + text = char(lines); + end + text = string(text); + if ~endsWith(string(text), newline) + text = text + newline; + end + text = char(text); +end + +function ensureParent(filepath) + parent = fileparts(filepath); + if ~isempty(parent) && exist(parent, "dir") ~= 7 + mkdir(parent); + end +end diff --git a/tests/suites/apps/electrochem/test_chronoOverlayExport.m b/tests/unit/apps/electrochem/LegacyChronoOverlayExportTest.m similarity index 84% rename from tests/suites/apps/electrochem/test_chronoOverlayExport.m rename to tests/unit/apps/electrochem/LegacyChronoOverlayExportTest.m index a655402..53d8dc0 100644 --- a/tests/suites/apps/electrochem/test_chronoOverlayExport.m +++ b/tests/unit/apps/electrochem/LegacyChronoOverlayExportTest.m @@ -1,4 +1,15 @@ -function test_chronoOverlayExport() +classdef LegacyChronoOverlayExportTest < matlab.unittest.TestCase + %LEGACYCHRONOOVERLAYEXPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_chronoOverlayExport(testCase) + setupLabKitTestPath(); + legacy_test_chronoOverlayExport(); + end + end +end + +function legacy_test_chronoOverlayExport() %TEST_CHRONOOVERLAYEXPORT Verify chrono overlay alignment and export tables. checkGapCenterAlignment(); @@ -17,7 +28,7 @@ function checkGapCenterAlignment() 'gap_end', 0.5, ... 'method', 'synthetic'); - [aligned, msg] = labkit_ChronoOverlay_app('__labkit_test__', 'alignByPulseGap', item); + [aligned, msg] = electrochemWorkflow("chronoOverlay", "alignByPulseGap", item); assertClose(aligned.alignTime, 0.4, 1e-12, ... 'Chrono overlay gap-center align time'); @@ -37,7 +48,7 @@ function checkFallbackAlignment() item.Im = zeros(size(item.t)); item.pulse = struct('ok', false, 'message', 'synthetic pulse not found'); - [aligned, msg] = labkit_ChronoOverlay_app('__labkit_test__', 'alignByPulseGap', item); + [aligned, msg] = electrochemWorkflow("chronoOverlay", "alignByPulseGap", item); assertClose(aligned.alignTime, 2, 1e-12, ... 'Chrono overlay fallback align time'); @@ -54,7 +65,7 @@ function checkMergedExportInterpolation() [100; 200], [10; 20]); itemC = makeOverlayItem('single sample.DTA', 0, 42, 5); - T = labkit_ChronoOverlay_app('__labkit_test__', 'buildOverlayExportTable', ... + T = electrochemWorkflow("chronoOverlay", "buildOverlayExportTable", ... [itemA, itemB, itemC]); assertClose(T.TimeGapCenterAligned_s, [-1; -0.5; 0; 0.5; 1], 1e-12, ... diff --git a/tests/suites/apps/electrochem/test_cicExport.m b/tests/unit/apps/electrochem/LegacyCicExportTest.m similarity index 85% rename from tests/suites/apps/electrochem/test_cicExport.m rename to tests/unit/apps/electrochem/LegacyCicExportTest.m index 6cec2ad..085d334 100644 --- a/tests/suites/apps/electrochem/test_cicExport.m +++ b/tests/unit/apps/electrochem/LegacyCicExportTest.m @@ -1,4 +1,15 @@ -function test_cicExport() +classdef LegacyCicExportTest < matlab.unittest.TestCase + %LEGACYCICEXPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_cicExport(testCase) + setupLabKitTestPath(); + legacy_test_cicExport(); + end + end +end + +function legacy_test_cicExport() %TEST_CICEXPORT Verify app-side CIC result/export table helpers. item = makeChronoFixtureItem('', 'chrono "cic".DTA'); @@ -67,17 +78,17 @@ function deleteIfExists(filepath) end function A = computeCIC(item, opts) - A = labkit_CIC_app('__labkit_test__', 'computeCIC', item, opts); + A = electrochemWorkflow("cic", "computeCIC", item, opts); end function T = buildCICResultsTable(items, unitLabel) - T = labkit_CIC_app('__labkit_test__', 'buildResultsTable', items, unitLabel); + T = electrochemWorkflow("cic", "buildResultsTable", items, unitLabel); end function [C, cols] = buildCICBatchTableData(items, unitLabel) - [C, cols] = labkit_CIC_app('__labkit_test__', 'buildBatchTableData', items, unitLabel); + [C, cols] = electrochemWorkflow("cic", "buildBatchTableData", items, unitLabel); end function writeCICResultsCSV(items, filepath, unitLabel) - labkit_CIC_app('__labkit_test__', 'writeResultsCSV', items, filepath, unitLabel); + electrochemWorkflow("cic", "writeResultsCSV", items, filepath, unitLabel); end diff --git a/tests/suites/apps/electrochem/test_computeCIC.m b/tests/unit/apps/electrochem/LegacyComputeCICTest.m similarity index 89% rename from tests/suites/apps/electrochem/test_computeCIC.m rename to tests/unit/apps/electrochem/LegacyComputeCICTest.m index ad3967f..e2e698c 100644 --- a/tests/suites/apps/electrochem/test_computeCIC.m +++ b/tests/unit/apps/electrochem/LegacyComputeCICTest.m @@ -1,4 +1,15 @@ -function test_computeCIC() +classdef LegacyComputeCICTest < matlab.unittest.TestCase + %LEGACYCOMPUTECICTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_computeCIC(testCase) + setupLabKitTestPath(); + legacy_test_computeCIC(); + end + end +end + +function legacy_test_computeCIC() %TEST_COMPUTECIC Verify app-side CIC / voltage-transient analysis. item = makeChronoFixtureItem(); @@ -67,5 +78,5 @@ function test_computeCIC() end function A = computeCIC(item, opts) - A = labkit_CIC_app('__labkit_test__', 'computeCIC', item, opts); + A = electrochemWorkflow("cic", "computeCIC", item, opts); end diff --git a/tests/suites/apps/electrochem/test_computeCSC.m b/tests/unit/apps/electrochem/LegacyComputeCSCTest.m similarity index 91% rename from tests/suites/apps/electrochem/test_computeCSC.m rename to tests/unit/apps/electrochem/LegacyComputeCSCTest.m index f24a701..d4adb3c 100644 --- a/tests/suites/apps/electrochem/test_computeCSC.m +++ b/tests/unit/apps/electrochem/LegacyComputeCSCTest.m @@ -1,4 +1,15 @@ -function test_computeCSC() +classdef LegacyComputeCSCTest < matlab.unittest.TestCase + %LEGACYCOMPUTECSCTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_computeCSC(testCase) + setupLabKitTestPath(); + legacy_test_computeCSC(); + end + end +end + +function legacy_test_computeCSC() %TEST_COMPUTECSC Verify CV/CT charge and CSC app analysis. fixture = dtaFixturePath('cv_cyclic_voltammetry_pt_reference.DTA'); @@ -80,5 +91,5 @@ function test_computeCSC() end function A = computeCSC(curve, opts) - A = labkit_CSC_app('__labkit_test__', 'computeCSC', curve, opts); + A = electrochemWorkflow("csc", "computeCSC", curve, opts); end diff --git a/tests/suites/apps/electrochem/test_computeVTResistance.m b/tests/unit/apps/electrochem/LegacyComputeVTResistanceTest.m similarity index 87% rename from tests/suites/apps/electrochem/test_computeVTResistance.m rename to tests/unit/apps/electrochem/LegacyComputeVTResistanceTest.m index ab5463c..ab09243 100644 --- a/tests/suites/apps/electrochem/test_computeVTResistance.m +++ b/tests/unit/apps/electrochem/LegacyComputeVTResistanceTest.m @@ -1,4 +1,15 @@ -function test_computeVTResistance() +classdef LegacyComputeVTResistanceTest < matlab.unittest.TestCase + %LEGACYCOMPUTEVTRESISTANCETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_computeVTResistance(testCase) + setupLabKitTestPath(); + legacy_test_computeVTResistance(); + end + end +end + +function legacy_test_computeVTResistance() %TEST_COMPUTEVTRESISTANCE Verify VT resistance app analysis. item = makeChronoFixtureItem(); @@ -55,5 +66,5 @@ function test_computeVTResistance() end function A = computeVTResistance(item, opts) - A = labkit_VTResistance_app('__labkit_test__', 'computeResistance', item, opts); + A = electrochemWorkflow("vtResistance", "computeResistance", item, opts); end diff --git a/tests/suites/apps/electrochem/test_eisOverlayExport.m b/tests/unit/apps/electrochem/LegacyEisOverlayExportTest.m similarity index 72% rename from tests/suites/apps/electrochem/test_eisOverlayExport.m rename to tests/unit/apps/electrochem/LegacyEisOverlayExportTest.m index 8e85f0a..c2f4ce7 100644 --- a/tests/suites/apps/electrochem/test_eisOverlayExport.m +++ b/tests/unit/apps/electrochem/LegacyEisOverlayExportTest.m @@ -1,4 +1,15 @@ -function test_eisOverlayExport() +classdef LegacyEisOverlayExportTest < matlab.unittest.TestCase + %LEGACYEISOVERLAYEXPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_eisOverlayExport(testCase) + setupLabKitTestPath(); + legacy_test_eisOverlayExport(); + end + end +end + +function legacy_test_eisOverlayExport() %TEST_EISOVERLAYEXPORT Verify EIS item schema and export/plot contracts. root = testRepoRoot(); @@ -29,7 +40,7 @@ function test_eisOverlayExport() assertClose(item.Vdc_V, item.Vdc, 'EIS normalized Vdc alias'); appFile = appEntryFile(root, 'labkit_EIS_app'); - source = fileread(appFile); + source = readAppOwnedSource(appFile); assert(contains(source, '''Freq (Hz)''') && contains(source, '''Zreal (ohm)''') && ... contains(source, '''-Zimag (ohm)'''), ... 'EIS app should preserve legacy axis labels.'); @@ -38,10 +49,24 @@ function test_eisOverlayExport() assert(contains(source, 'axis(ax, ''equal'')'), ... 'EIS app should preserve equal-axis Nyquist plot behavior.'); - zreal = labkit_EIS_app('__labkit_test__', 'valuesForAxis', item, 'Zreal (ohm)'); + zreal = electrochemWorkflow("eis", "valuesForAxis", item, 'Zreal (ohm)'); assertClose(zreal, item.Zreal, 'EIS app axis-value hook should preserve Zreal values'); - T = labkit_EIS_app('__labkit_test__', 'buildExportTable', item, ... + T = electrochemWorkflow("eis", "buildExportTable", item, ... 'Zreal (ohm)', '-Zimag (ohm)', false, false); assert(isequal(T.Properties.VariableNames(1), {'RowIndex'}), ... 'EIS export table hook should preserve RowIndex as the first column.'); end + +function source = readAppOwnedSource(appFile) + appDir = fileparts(appFile); + sourceParts = {fileread(appFile)}; + privateDir = fullfile(appDir, 'private'); + if exist(privateDir, 'dir') == 7 + fileEntries = dir(fullfile(privateDir, '*.m')); + fileNames = sort({fileEntries.name}); + for iFile = 1:numel(fileNames) + sourceParts{end+1} = fileread(fullfile(privateDir, fileNames{iFile})); %#ok + end + end + source = strjoin(sourceParts, newline); +end diff --git a/tests/suites/apps/electrochem/test_vtResistanceExport.m b/tests/unit/apps/electrochem/LegacyVtResistanceExportTest.m similarity index 83% rename from tests/suites/apps/electrochem/test_vtResistanceExport.m rename to tests/unit/apps/electrochem/LegacyVtResistanceExportTest.m index 971c91c..517fc58 100644 --- a/tests/suites/apps/electrochem/test_vtResistanceExport.m +++ b/tests/unit/apps/electrochem/LegacyVtResistanceExportTest.m @@ -1,4 +1,15 @@ -function test_vtResistanceExport() +classdef LegacyVtResistanceExportTest < matlab.unittest.TestCase + %LEGACYVTRESISTANCEEXPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_vtResistanceExport(testCase) + setupLabKitTestPath(); + legacy_test_vtResistanceExport(); + end + end +end + +function legacy_test_vtResistanceExport() %TEST_VTRESISTANCEEXPORT Verify VT resistance result/export table helpers. item = makeChronoFixtureItem('', 'chrono "vt".DTA'); @@ -57,17 +68,17 @@ function deleteIfExists(filepath) end function A = computeVTResistance(item, opts) - A = labkit_VTResistance_app('__labkit_test__', 'computeResistance', item, opts); + A = electrochemWorkflow("vtResistance", "computeResistance", item, opts); end function T = buildVTResultsTable(items) - T = labkit_VTResistance_app('__labkit_test__', 'buildResultsTable', items); + T = electrochemWorkflow("vtResistance", "buildResultsTable", items); end function C = buildVTBatchTableData(items) - C = labkit_VTResistance_app('__labkit_test__', 'buildBatchTableData', items); + C = electrochemWorkflow("vtResistance", "buildBatchTableData", items); end function writeVTResultsCSV(items, filepath) - labkit_VTResistance_app('__labkit_test__', 'writeResultsCSV', items, filepath); + electrochemWorkflow("vtResistance", "writeResultsCSV", items, filepath); end diff --git a/tests/suites/apps/image_measurement/test_focusStackFusion.m b/tests/unit/apps/image_measurement/LegacyFocusStackFusionTest.m similarity index 84% rename from tests/suites/apps/image_measurement/test_focusStackFusion.m rename to tests/unit/apps/image_measurement/LegacyFocusStackFusionTest.m index 297a6d8..274b7c5 100644 --- a/tests/suites/apps/image_measurement/test_focusStackFusion.m +++ b/tests/unit/apps/image_measurement/LegacyFocusStackFusionTest.m @@ -1,4 +1,15 @@ -function test_focusStackFusion() +classdef LegacyFocusStackFusionTest < matlab.unittest.TestCase + %LEGACYFOCUSSTACKFUSIONTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_focusStackFusion(testCase) + setupLabKitTestPath(); + legacy_test_focusStackFusion(); + end + end +end + +function legacy_test_focusStackFusion() %TEST_FOCUSSTACKFUSION Verify focus-stack fusion app calculations. checkSyntheticFocusSelection(); @@ -13,8 +24,8 @@ function checkSyntheticFocusSelection() [nearImage, farImage, mid] = syntheticFocusPair(); opts = struct('focusWindow', 5, 'smoothRadius', 0, 'minConfidence', 0); - result = labkit_FocusStack_app('__labkit_test__', ... - 'computeFocusStack', {nearImage, farImage}, opts); + result = focusStackWorkflow( ... + "computeFocusStack", {nearImage, farImage}, opts); assert(result.ok, 'Focus stack should succeed for a two-image synthetic stack.'); assert(result.inputCount == 2, 'Input image count changed.'); @@ -41,12 +52,12 @@ function checkSyntheticFocusSelection() function checkSummaryTableContract() [nearImage, farImage] = syntheticFocusPair(); - result = labkit_FocusStack_app('__labkit_test__', ... - 'computeFocusStack', {nearImage, farImage}, ... + result = focusStackWorkflow( ... + "computeFocusStack", {nearImage, farImage}, ... struct('focusWindow', 5, 'smoothRadius', 1, 'minConfidence', 0.05)); - T = labkit_FocusStack_app('__labkit_test__', ... - 'buildFocusStackSummaryTable', result, ["slice_a.png"; "slice_b.png"]); + T = focusStackWorkflow( ... + "buildFocusStackSummaryTable", result, ["slice_a.png"; "slice_b.png"]); assert(isequal(T.Properties.VariableNames, expectedSummaryColumns()), ... 'Focus stack summary columns changed.'); @@ -70,7 +81,7 @@ function checkFolderDiscovery() fprintf(fid, 'not an image fixture'); fclose(fid); - paths = labkit_FocusStack_app('__labkit_test__', 'findFocusStackImages', folder); + paths = focusStackWorkflow("findFocusStackImages", folder); names = cell(numel(paths), 1); for k = 1:numel(paths) [~, base, ext] = fileparts(char(paths(k))); @@ -86,19 +97,19 @@ function checkSelectedFileSelection() mkdir(folder); cleanup = onCleanup(@() removeTempFolder(folder)); %#ok - paths = labkit_FocusStack_app('__labkit_test__', ... - 'selectedFocusImagePaths', {'frame_b.png', 'frame_a.tif'}, folder); + paths = focusStackWorkflow( ... + "selectedFocusImagePaths", {'frame_b.png', 'frame_a.tif'}, folder); names = fileNames(paths); assert(isequal(names, {'frame_a.tif'; 'frame_b.png'}), ... 'Selected image files should be normalized and sorted by name.'); - onePath = labkit_FocusStack_app('__labkit_test__', ... - 'selectedFocusImagePaths', 'frame_c.jpg', folder); + onePath = focusStackWorkflow( ... + "selectedFocusImagePaths", 'frame_c.jpg', folder); assert(numel(onePath) == 1 && endsWith(onePath, "frame_c.jpg"), ... 'Single-file selection should be accepted for preview before stacking.'); - assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... - 'selectedFocusImagePaths', 'notes.txt', folder), ... + assertThrows(@() focusStackWorkflow( ... + "selectedFocusImagePaths", 'notes.txt', folder), ... 'labkit_FocusStack_app:UnsupportedImageFile', ... 'Manual selection should reject unsupported file types.'); end @@ -107,8 +118,8 @@ function checkRegistrationImprovesSyntheticDrift() reference = syntheticRegistrationImage(); moving = integerTranslateImage(reference, -3, 4, median(reference(:))); - [aligned, lines] = labkit_FocusStack_app('__labkit_test__', ... - 'alignFocusStackImages', {moving, reference}); + [aligned, lines] = focusStackWorkflow( ... + "alignFocusStackImages", {moving, reference}); beforeErr = mean((im2double(moving(:)) - im2double(reference(:))) .^ 2); afterErr = mean((im2double(aligned{1}(:)) - im2double(reference(:))) .^ 2); @@ -128,12 +139,12 @@ function checkRegistrationImprovesSyntheticDrift() end function checkInvalidInputs() - assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... - 'computeFocusStack', {zeros(8, 8)}, struct()), ... + assertThrows(@() focusStackWorkflow( ... + "computeFocusStack", {zeros(8, 8)}, struct()), ... 'labkit_FocusStack_app:NotEnoughImages', ... 'Single-image stacks should be rejected.'); - assertThrows(@() labkit_FocusStack_app('__labkit_test__', ... - 'computeFocusStack', {zeros(8, 8), zeros(8, 8)}, ... + assertThrows(@() focusStackWorkflow( ... + "computeFocusStack", {zeros(8, 8), zeros(8, 8)}, ... struct('focusWindow', 0)), ... 'MATLAB:expectedPositive', ... 'Invalid focus window should be rejected.'); diff --git a/tests/suites/apps/image_measurement/test_imageCurvatureMeasurement.m b/tests/unit/apps/image_measurement/LegacyImageCurvatureMeasurementTest.m similarity index 82% rename from tests/suites/apps/image_measurement/test_imageCurvatureMeasurement.m rename to tests/unit/apps/image_measurement/LegacyImageCurvatureMeasurementTest.m index 09f318b..5146f9b 100644 --- a/tests/suites/apps/image_measurement/test_imageCurvatureMeasurement.m +++ b/tests/unit/apps/image_measurement/LegacyImageCurvatureMeasurementTest.m @@ -1,4 +1,15 @@ -function test_imageCurvatureMeasurement() +classdef LegacyImageCurvatureMeasurementTest < matlab.unittest.TestCase + %LEGACYIMAGECURVATUREMEASUREMENTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_imageCurvatureMeasurement(testCase) + setupLabKitTestPath(); + legacy_test_imageCurvatureMeasurement(); + end + end +end + +function legacy_test_imageCurvatureMeasurement() %TEST_IMAGECURVATUREMEASUREMENT Verify image curvature app calculations. checkCircularFitWithMeasuredScale(); @@ -22,7 +33,7 @@ function checkCircularFitWithMeasuredScale() 'scaleUnit', 'mm', ... 'doDensify', false, ... 'denseN', 200); - fit = labkit_CurvatureMeasurement_app('__labkit_test__', 'computeCurvatureFit', x, y, opts); + fit = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, opts); assert(fit.ok, 'Curvature fit should succeed for circular points.'); assertClose(fit.xc_px, xc, 1e-6, 'Fitted center x changed.'); @@ -34,7 +45,7 @@ function checkCircularFitWithMeasuredScale() assertClose(fit.curveLength_px, sum(hypot(diff(x), diff(y))), 1e-9, ... 'Fitted result should include curve length in pixels.'); - T = labkit_CurvatureMeasurement_app('__labkit_test__', 'buildCurvatureResultTable', ... + T = curvatureMeasurementWorkflow("buildCurvatureResultTable", ... fit, "sample.png"); assert(isequal(T.Properties.VariableNames, expectedResultColumns()), ... 'Curvature result table columns changed.'); @@ -50,7 +61,7 @@ function checkPixelAndTypedScaleModes() x = 12 + 30*cos(theta); y = 22 + 30*sin(theta); - pxOnly = labkit_CurvatureMeasurement_app('__labkit_test__', 'computeCurvatureFit', x, y, ... + pxOnly = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, ... struct('referencePx', NaN, 'referenceLength', 0, 'scaleUnit', 'um', ... 'doDensify', false)); assert(pxOnly.ok, 'Curvature fit should work without a physical scale.'); @@ -62,7 +73,7 @@ function checkPixelAndTypedScaleModes() assertClose(pxOnly.kappa_show, pxOnly.kappa_per_px, 1e-12, ... 'Pixel-only displayed curvature should equal pixel curvature'); - typedScale = labkit_CurvatureMeasurement_app('__labkit_test__', 'computeCurvatureFit', x, y, ... + typedScale = curvatureMeasurementWorkflow("computeCurvatureFit", x, y, ... struct('referencePx', 15, 'referenceLength', 1, 'scaleUnit', 'mm', ... 'doDensify', false)); assert(typedScale.ok, 'Typed reference scale should produce a fit.'); @@ -79,8 +90,8 @@ function checkCurveLengthMeasurement() x = [0; 3; 6]; y = [0; 4; 8]; - pxLength = labkit_CurvatureMeasurement_app('__labkit_test__', ... - 'computeCurveLength', x, y, ... + pxLength = curvatureMeasurementWorkflow( ... + "computeCurveLength", x, y, ... struct('referencePx', NaN, 'referenceLength', 0, 'scaleUnit', 'um')); assert(pxLength.ok, 'Curve length should succeed for two or more points.'); assertClose(pxLength.length_px, 10, 1e-12, ... @@ -90,8 +101,8 @@ function checkCurveLengthMeasurement() assert(strcmp(pxLength.unitLen, 'px'), ... 'Pixel-only curve length unit changed.'); - mmLength = labkit_CurvatureMeasurement_app('__labkit_test__', ... - 'computeCurveLength', x, y, ... + mmLength = curvatureMeasurementWorkflow( ... + "computeCurveLength", x, y, ... struct('referencePx', 5, 'referenceLength', 1, 'scaleUnit', 'mm')); assert(mmLength.ok, 'Typed reference scale should scale curve length.'); assertClose(mmLength.length_show, 2, 1e-12, ... @@ -108,8 +119,8 @@ function checkDensifyUsesCurvePath() curveX = [0; 10; 20]; curveY = [0; 10; 0]; - fit = labkit_CurvatureMeasurement_app('__labkit_test__', ... - 'computeCurvatureFit', anchorX, anchorY, ... + fit = curvatureMeasurementWorkflow( ... + "computeCurvatureFit", anchorX, anchorY, ... struct('referencePx', NaN, 'referenceLength', 0, ... 'scaleUnit', 'um', 'doDensify', true, 'denseN', 5, ... 'fitPathX', curveX, 'fitPathY', curveY)); @@ -127,16 +138,16 @@ function checkInvalidCurvePoints() opts = struct('referencePx', NaN, 'referenceLength', 0, ... 'scaleUnit', 'um', 'doDensify', false); - assertThrows(@() labkit_CurvatureMeasurement_app( ... - '__labkit_test__', 'computeCurvatureFit', [5; 5; 5], [7; 7; 7], opts), ... + assertThrows(@() curvatureMeasurementWorkflow( ... + "computeCurvatureFit", [5; 5; 5], [7; 7; 7], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughPoints', ... 'Duplicate-only curve points should be rejected.'); - assertThrows(@() labkit_CurvatureMeasurement_app( ... - '__labkit_test__', 'computeCurvatureFit', [1; 2], [3; 4], opts), ... + assertThrows(@() curvatureMeasurementWorkflow( ... + "computeCurvatureFit", [1; 2], [3; 4], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughPoints', ... 'Two unique curve points should be rejected.'); - assertThrows(@() labkit_CurvatureMeasurement_app( ... - '__labkit_test__', 'computeCurveLength', [1], [3], opts), ... + assertThrows(@() curvatureMeasurementWorkflow( ... + "computeCurveLength", [1], [3], opts), ... 'labkit_CurvatureMeasurement_app:NotEnoughLengthPoints', ... 'Single-point curve length should be rejected.'); end diff --git a/tests/suites/labkit/biosignal/test_biosignalDelimitedImport.m b/tests/unit/labkit/biosignal/LegacyBiosignalDelimitedImportTest.m similarity index 94% rename from tests/suites/labkit/biosignal/test_biosignalDelimitedImport.m rename to tests/unit/labkit/biosignal/LegacyBiosignalDelimitedImportTest.m index 2b6e84d..3abeb9f 100644 --- a/tests/suites/labkit/biosignal/test_biosignalDelimitedImport.m +++ b/tests/unit/labkit/biosignal/LegacyBiosignalDelimitedImportTest.m @@ -1,4 +1,15 @@ -function test_biosignalDelimitedImport() +classdef LegacyBiosignalDelimitedImportTest < matlab.unittest.TestCase + %LEGACYBIOSIGNALDELIMITEDIMPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_biosignalDelimitedImport(testCase) + setupLabKitTestPath(); + legacy_test_biosignalDelimitedImport(); + end + end +end + +function legacy_test_biosignalDelimitedImport() %TEST_BIOSIGNALDELIMITEDIMPORT Verify CSV/TXT time inference and repair. csvNoTime = [tempname(tempdir) '.csv']; diff --git a/tests/suites/labkit/biosignal/test_biosignalProcessing.m b/tests/unit/labkit/biosignal/LegacyBiosignalProcessingTest.m similarity index 84% rename from tests/suites/labkit/biosignal/test_biosignalProcessing.m rename to tests/unit/labkit/biosignal/LegacyBiosignalProcessingTest.m index c8898be..3e02fe7 100644 --- a/tests/suites/labkit/biosignal/test_biosignalProcessing.m +++ b/tests/unit/labkit/biosignal/LegacyBiosignalProcessingTest.m @@ -1,4 +1,15 @@ -function test_biosignalProcessing() +classdef LegacyBiosignalProcessingTest < matlab.unittest.TestCase + %LEGACYBIOSIGNALPROCESSINGTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_biosignalProcessing(testCase) + setupLabKitTestPath(); + legacy_test_biosignalProcessing(); + end + end +end + +function legacy_test_biosignalProcessing() %TEST_BIOSIGNALPROCESSING Verify signal filtering and crop/filter composition. signal = syntheticSignal(); diff --git a/tests/suites/labkit/biosignal/test_biosignalRecordingImport.m b/tests/unit/labkit/biosignal/LegacyBiosignalRecordingImportTest.m similarity index 75% rename from tests/suites/labkit/biosignal/test_biosignalRecordingImport.m rename to tests/unit/labkit/biosignal/LegacyBiosignalRecordingImportTest.m index f8b5a54..b38ca59 100644 --- a/tests/suites/labkit/biosignal/test_biosignalRecordingImport.m +++ b/tests/unit/labkit/biosignal/LegacyBiosignalRecordingImportTest.m @@ -1,4 +1,15 @@ -function test_biosignalRecordingImport() +classdef LegacyBiosignalRecordingImportTest < matlab.unittest.TestCase + %LEGACYBIOSIGNALRECORDINGIMPORTTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_biosignalRecordingImport(testCase) + setupLabKitTestPath(); + legacy_test_biosignalRecordingImport(); + end + end +end + +function legacy_test_biosignalRecordingImport() %TEST_BIOSIGNALRECORDINGIMPORT Verify MAT/timetable import and channel access. tempFile = [tempname(tempdir) '.mat']; diff --git a/tests/suites/labkit/biosignal/test_biosignalSegmentsMeasurements.m b/tests/unit/labkit/biosignal/LegacyBiosignalSegmentsMeasurementsTest.m similarity index 81% rename from tests/suites/labkit/biosignal/test_biosignalSegmentsMeasurements.m rename to tests/unit/labkit/biosignal/LegacyBiosignalSegmentsMeasurementsTest.m index 0e07b96..554eecd 100644 --- a/tests/suites/labkit/biosignal/test_biosignalSegmentsMeasurements.m +++ b/tests/unit/labkit/biosignal/LegacyBiosignalSegmentsMeasurementsTest.m @@ -1,4 +1,15 @@ -function test_biosignalSegmentsMeasurements() +classdef LegacyBiosignalSegmentsMeasurementsTest < matlab.unittest.TestCase + %LEGACYBIOSIGNALSEGMENTSMEASUREMENTSTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_biosignalSegmentsMeasurements(testCase) + setupLabKitTestPath(); + legacy_test_biosignalSegmentsMeasurements(); + end + end +end + +function legacy_test_biosignalSegmentsMeasurements() %TEST_BIOSIGNALSEGMENTSMEASUREMENTS Verify segments, templates, measurements, and groups. signal = labkit.biosignal.filterSignal(syntheticSignal(), ... diff --git a/tests/suites/labkit/biosignal/test_ecgPeakDetection.m b/tests/unit/labkit/biosignal/LegacyEcgPeakDetectionTest.m similarity index 90% rename from tests/suites/labkit/biosignal/test_ecgPeakDetection.m rename to tests/unit/labkit/biosignal/LegacyEcgPeakDetectionTest.m index 2de2dcc..7437cc9 100644 --- a/tests/suites/labkit/biosignal/test_ecgPeakDetection.m +++ b/tests/unit/labkit/biosignal/LegacyEcgPeakDetectionTest.m @@ -1,4 +1,15 @@ -function test_ecgPeakDetection() +classdef LegacyEcgPeakDetectionTest < matlab.unittest.TestCase + %LEGACYECGPEAKDETECTIONTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_ecgPeakDetection(testCase) + setupLabKitTestPath(); + legacy_test_ecgPeakDetection(); + end + end +end + +function legacy_test_ecgPeakDetection() %TEST_ECGPEAKDETECTION Verify ECG peak detector methods and post-processing. signal = labkit.biosignal.filterSignal(syntheticSignal(), ... diff --git a/tests/suites/labkit/dta/test_detectPulses.m b/tests/unit/labkit/dta/LegacyDetectPulsesTest.m similarity index 93% rename from tests/suites/labkit/dta/test_detectPulses.m rename to tests/unit/labkit/dta/LegacyDetectPulsesTest.m index e435cd4..3880a56 100644 --- a/tests/suites/labkit/dta/test_detectPulses.m +++ b/tests/unit/labkit/dta/LegacyDetectPulsesTest.m @@ -1,4 +1,15 @@ -function test_detectPulses() +classdef LegacyDetectPulsesTest < matlab.unittest.TestCase + %LEGACYDETECTPULSESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_detectPulses(testCase) + setupLabKitTestPath(); + legacy_test_detectPulses(); + end + end +end + +function legacy_test_detectPulses() %TEST_DETECTPULSES Verify extracted pulse detection behavior. currentFixture = dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA'); diff --git a/tests/suites/labkit/dta/test_dtaFacade.m b/tests/unit/labkit/dta/LegacyDtaFacadeTest.m similarity index 96% rename from tests/suites/labkit/dta/test_dtaFacade.m rename to tests/unit/labkit/dta/LegacyDtaFacadeTest.m index 19fa0ce..28d41a6 100644 --- a/tests/suites/labkit/dta/test_dtaFacade.m +++ b/tests/unit/labkit/dta/LegacyDtaFacadeTest.m @@ -1,4 +1,15 @@ -function test_dtaFacade() +classdef LegacyDtaFacadeTest < matlab.unittest.TestCase + %LEGACYDTAFACADETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_dtaFacade(testCase) + setupLabKitTestPath(); + legacy_test_dtaFacade(); + end + end +end + +function legacy_test_dtaFacade() %TEST_DTAFACADE Verify GUI-free DTA type detection and loading facade. fixtureDir = dtaFixtureDir(); diff --git a/tests/suites/labkit/dta/test_dtaSessionFacade.m b/tests/unit/labkit/dta/LegacyDtaSessionFacadeTest.m similarity index 88% rename from tests/suites/labkit/dta/test_dtaSessionFacade.m rename to tests/unit/labkit/dta/LegacyDtaSessionFacadeTest.m index 2daf07e..3df1fb9 100644 --- a/tests/suites/labkit/dta/test_dtaSessionFacade.m +++ b/tests/unit/labkit/dta/LegacyDtaSessionFacadeTest.m @@ -1,4 +1,15 @@ -function test_dtaSessionFacade() +classdef LegacyDtaSessionFacadeTest < matlab.unittest.TestCase + %LEGACYDTASESSIONFACADETEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_dtaSessionFacade(testCase) + setupLabKitTestPath(); + legacy_test_dtaSessionFacade(); + end + end +end + +function legacy_test_dtaSessionFacade() %TEST_DTASESSIONFACADE Verify app-facing DTA session helpers. fixture = dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA'); diff --git a/tests/suites/labkit/dta/test_makeChronoItem.m b/tests/unit/labkit/dta/LegacyMakeChronoItemTest.m similarity index 80% rename from tests/suites/labkit/dta/test_makeChronoItem.m rename to tests/unit/labkit/dta/LegacyMakeChronoItemTest.m index 1af9eaf..e474312 100644 --- a/tests/suites/labkit/dta/test_makeChronoItem.m +++ b/tests/unit/labkit/dta/LegacyMakeChronoItemTest.m @@ -1,4 +1,15 @@ -function test_makeChronoItem() +classdef LegacyMakeChronoItemTest < matlab.unittest.TestCase + %LEGACYMAKECHRONOITEMTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_makeChronoItem(testCase) + setupLabKitTestPath(); + legacy_test_makeChronoItem(); + end + end +end + +function legacy_test_makeChronoItem() %TEST_MAKECHRONOITEM Verify chrono item construction through the DTA facade. fixture = dtaFixturePath('chrono_chronopot_current_pulse_0p2ms.DTA'); diff --git a/tests/suites/labkit/dta/test_parseCVCTDTA.m b/tests/unit/labkit/dta/LegacyParseCVCTDTATest.m similarity index 88% rename from tests/suites/labkit/dta/test_parseCVCTDTA.m rename to tests/unit/labkit/dta/LegacyParseCVCTDTATest.m index 8cf7c4b..051735f 100644 --- a/tests/suites/labkit/dta/test_parseCVCTDTA.m +++ b/tests/unit/labkit/dta/LegacyParseCVCTDTATest.m @@ -1,4 +1,15 @@ -function test_parseCVCTDTA() +classdef LegacyParseCVCTDTATest < matlab.unittest.TestCase + %LEGACYPARSECVCTDTATEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_parseCVCTDTA(testCase) + setupLabKitTestPath(); + legacy_test_parseCVCTDTA(); + end + end +end + +function legacy_test_parseCVCTDTA() %TEST_PARSECVCTDTA Verify extracted CV/CT parser behavior. fixtureFile = dtaFixturePath('cv_cyclic_voltammetry_pt_reference.DTA'); diff --git a/tests/suites/labkit/dta/test_parseChronoDTA.m b/tests/unit/labkit/dta/LegacyParseChronoDTATest.m similarity index 91% rename from tests/suites/labkit/dta/test_parseChronoDTA.m rename to tests/unit/labkit/dta/LegacyParseChronoDTATest.m index 29ad04c..e3d5554 100644 --- a/tests/suites/labkit/dta/test_parseChronoDTA.m +++ b/tests/unit/labkit/dta/LegacyParseChronoDTATest.m @@ -1,4 +1,15 @@ -function test_parseChronoDTA() +classdef LegacyParseChronoDTATest < matlab.unittest.TestCase + %LEGACYPARSECHRONODTATEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_parseChronoDTA(testCase) + setupLabKitTestPath(); + legacy_test_parseChronoDTA(); + end + end +end + +function legacy_test_parseChronoDTA() %TEST_PARSECHRONODTA Verify extracted chrono DTA parser and accessors. filepaths = labkit.dta.findFiles(dtaFixtureDir()); diff --git a/tests/suites/labkit/dta/test_parseEISDTA.m b/tests/unit/labkit/dta/LegacyParseEISDTATest.m similarity index 84% rename from tests/suites/labkit/dta/test_parseEISDTA.m rename to tests/unit/labkit/dta/LegacyParseEISDTATest.m index 390b5e0..745682f 100644 --- a/tests/suites/labkit/dta/test_parseEISDTA.m +++ b/tests/unit/labkit/dta/LegacyParseEISDTATest.m @@ -1,4 +1,15 @@ -function test_parseEISDTA() +classdef LegacyParseEISDTATest < matlab.unittest.TestCase + %LEGACYPARSEEISDTATEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_parseEISDTA(testCase) + setupLabKitTestPath(); + legacy_test_parseEISDTA(); + end + end +end + +function legacy_test_parseEISDTA() %TEST_PARSEEISDTA Verify extracted EIS parser and ZCURVE accessors. fixture = dtaFixturePath('eis_potentiostatic_zcurve.DTA'); diff --git a/tests/suites/labkit/dta/test_sessionUtilities.m b/tests/unit/labkit/dta/LegacySessionUtilitiesTest.m similarity index 82% rename from tests/suites/labkit/dta/test_sessionUtilities.m rename to tests/unit/labkit/dta/LegacySessionUtilitiesTest.m index efe7708..4cffc03 100644 --- a/tests/suites/labkit/dta/test_sessionUtilities.m +++ b/tests/unit/labkit/dta/LegacySessionUtilitiesTest.m @@ -1,4 +1,15 @@ -function test_sessionUtilities() +classdef LegacySessionUtilitiesTest < matlab.unittest.TestCase + %LEGACYSESSIONUTILITIESTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_sessionUtilities(testCase) + setupLabKitTestPath(); + legacy_test_sessionUtilities(); + end + end +end + +function legacy_test_sessionUtilities() %TEST_SESSIONUTILITIES Verify session save/load helpers. session = labkit.dta.makeSession('eis', struct('notes', 'demo notes')); diff --git a/tests/suites/labkit/ui/test_appHookHelpers.m b/tests/unit/labkit/ui/LegacyAppHookHelpersTest.m similarity index 75% rename from tests/suites/labkit/ui/test_appHookHelpers.m rename to tests/unit/labkit/ui/LegacyAppHookHelpersTest.m index 52c25a2..20b82fb 100644 --- a/tests/suites/labkit/ui/test_appHookHelpers.m +++ b/tests/unit/labkit/ui/LegacyAppHookHelpersTest.m @@ -1,4 +1,15 @@ -function test_appHookHelpers() +classdef LegacyAppHookHelpersTest < matlab.unittest.TestCase + %LEGACYAPPHOOKHELPERSTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_appHookHelpers(testCase) + setupLabKitTestPath(); + legacy_test_appHookHelpers(); + end + end +end + +function legacy_test_appHookHelpers() %TEST_APPHOOKHELPERS Verify internal app hook dispatch and debug log helpers. checkDebugLog(); @@ -93,66 +104,37 @@ function failingCallback(varargin) %#ok end function checkRequestDispatch() - handlers = struct( ... - 'command', {'echo'}, ... - 'minArgs', {1}, ... - 'maxArgs', {2}, ... - 'maxOutputs', {2}, ... - 'run', {@runEcho}); - - [handled, outputs, debug] = labkit.ui.app.dispatchRequest('probe_app', {}, 0, handlers); + [handled, outputs, debug] = labkit.ui.app.dispatchRequest('probe_app', {}, 0); assert(~handled && isempty(outputs) && ~debug.enabled, ... 'Empty app input should not be handled and should return a disabled debug log.'); - [handled, outputs] = labkit.ui.app.dispatchRequest( ... - 'probe_app', {'__labkit_test__', 'echo', 'one', 'two'}, 2, handlers); - assert(handled && isequal(outputs, {'one', 'two'}), ... - 'Test hook dispatch should return requested handler outputs.'); - [handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... - 'probe_app', {'__labkit_debug__', struct()}, 2, handlers); + 'probe_app', {'__labkit_debug__', struct()}, 2); assert(~handled && isempty(outputs) && debug.enabled && debug.traceEnabled, ... 'Debug hook dispatch should enable debug logging without consuming app launch.'); [handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... - 'probe_app', {'debug'}, 2, handlers); + 'probe_app', {'debug'}, 2); assert(~handled && isempty(outputs) && debug.enabled && debug.traceEnabled, ... 'Debug launch dispatch should accept the user-facing debug alias.'); [handled, outputs, debug] = labkit.ui.app.dispatchRequest( ... - 'probe_app', {'--debug', struct('traceEnabled', false)}, 2, handlers); + 'probe_app', {'--debug', struct('traceEnabled', false)}, 2); assert(~handled && isempty(outputs) && debug.enabled && ~debug.traceEnabled, ... 'Debug launch dispatch should preserve explicit traceEnabled=false.'); end function checkRequestErrors() - handlers = struct( ... - 'command', {'echo'}, ... - 'minArgs', {1}, ... - 'maxArgs', {1}, ... - 'maxOutputs', {1}, ... - 'run', {@runEcho}); - - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {42}, 0, handlers), ... + assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {42}, 0), ... 'probe_app:UnsupportedInput', 'Nonstrings should be unsupported app input.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'__labkit_test__'}, 0, handlers), ... - 'probe_app:InvalidTestRequest', 'Test hooks require a command name.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'__labkit_test__', 'missing'}, 0, handlers), ... - 'probe_app:UnknownTestCommand', 'Unknown test commands should fail with the canonical id.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'__labkit_test__', 'echo'}, 0, handlers), ... - 'probe_app:InvalidTestArguments', 'Invalid test argument counts should fail with the canonical id.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'__labkit_test__', 'echo', 'one'}, 2, handlers), ... - 'probe_app:TooManyOutputs', 'Too many requested outputs should fail with the canonical id.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug'}, 3, handlers), ... + assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'legacyCommand', 'arg'}, 0), ... + 'probe_app:UnsupportedInput', 'Non-debug string inputs should be rejected.'); + assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug'}, 3), ... 'probe_app:TooManyOutputs', 'Too many debug outputs should fail with the canonical id.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug', 42}, 0, handlers), ... - 'probe_app:InvalidTestRequest', 'Debug options should be a struct.'); - assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug', struct(), struct()}, 0, handlers), ... - 'probe_app:InvalidTestRequest', 'Debug requests should accept at most one options struct.'); -end - -function outputs = runEcho(args) - outputs = args; + assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug', 42}, 0), ... + 'probe_app:InvalidDebugOptions', 'Debug options should be a struct.'); + assertThrows(@() labkit.ui.app.dispatchRequest('probe_app', {'debug', struct(), struct()}, 0), ... + 'probe_app:InvalidDebugOptions', 'Debug requests should accept at most one options struct.'); end function assertThrows(fn, expectedIdentifier, label) diff --git a/tests/suites/labkit/ui/test_plotXY.m b/tests/unit/labkit/ui/LegacyPlotXYTest.m similarity index 86% rename from tests/suites/labkit/ui/test_plotXY.m rename to tests/unit/labkit/ui/LegacyPlotXYTest.m index bc22335..e7337b8 100644 --- a/tests/suites/labkit/ui/test_plotXY.m +++ b/tests/unit/labkit/ui/LegacyPlotXYTest.m @@ -1,4 +1,15 @@ -function test_plotXY() +classdef LegacyPlotXYTest < matlab.unittest.TestCase + %LEGACYPLOTXYTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_plotXY(testCase) + setupLabKitTestPath(); + legacy_test_plotXY(); + end + end +end + +function legacy_test_plotXY() %TEST_PLOTXY Verify prepared X/Y plotting helper behavior. curve = struct(); diff --git a/tests/suites/labkit/ui/test_scaleBarCalibration.m b/tests/unit/labkit/ui/LegacyScaleBarCalibrationTest.m similarity index 79% rename from tests/suites/labkit/ui/test_scaleBarCalibration.m rename to tests/unit/labkit/ui/LegacyScaleBarCalibrationTest.m index 0df80da..e8d4ae2 100644 --- a/tests/suites/labkit/ui/test_scaleBarCalibration.m +++ b/tests/unit/labkit/ui/LegacyScaleBarCalibrationTest.m @@ -1,4 +1,15 @@ -function test_scaleBarCalibration() +classdef LegacyScaleBarCalibrationTest < matlab.unittest.TestCase + %LEGACYSCALEBARCALIBRATIONTEST Official wrapper for migrated legacy coverage. + + methods (Test, TestTags = {'Unit'}) + function test_scaleBarCalibration(testCase) + setupLabKitTestPath(); + legacy_test_scaleBarCalibration(); + end + end +end + +function legacy_test_scaleBarCalibration() %TEST_SCALEBARCALIBRATION Verify reusable scale-bar calibration model. checkTypedCalibration(); diff --git a/tests/unit/project/PlatformSkeletonTest.m b/tests/unit/project/PlatformSkeletonTest.m new file mode 100644 index 0000000..acf629b --- /dev/null +++ b/tests/unit/project/PlatformSkeletonTest.m @@ -0,0 +1,60 @@ +classdef PlatformSkeletonTest < matlab.unittest.TestCase + %PLATFORMSKELETONTEST Seed official tests for the new LabKit platform. + % + % This class tests runner/support artifact contracts used by the official suite. + + methods (Test, TestTags = {'Unit', 'Smoke', 'Style'}) + function artifactPathsUseRoadmapLayout(testCase) + setupLabKitTestPath(); + paths = labkitArtifactPaths( ... + "Root", fullfile(tempdir, "labkit-artifacts-seed"), ... + "Create", false); + + testCase.verifyTrue(endsWith(string(paths.junitXml), ... + fullfile("test-results", "junit.xml"))); + testCase.verifyTrue(endsWith(string(paths.testHtml), ... + fullfile("test-results", "html"))); + testCase.verifyTrue(endsWith(string(paths.coberturaXml), ... + fullfile("coverage", "cobertura.xml"))); + testCase.verifyTrue(endsWith(string(paths.coverageHtml), ... + fullfile("coverage", "html"))); + testCase.verifyTrue(endsWith(string(paths.guiTrace), ... + fullfile("gui", "trace"))); + testCase.verifyTrue(endsWith(string(paths.guiSnapshots), ... + fullfile("gui", "snapshots"))); + end + + function traceArtifactsAreStructuredAndSanitized(testCase) + setupLabKitTestPath(); + paths = labkitArtifactPaths( ... + "Root", fullfile(tempdir, "labkit-trace-seed"), ... + "Create", true); + jsonlPath = fullfile(paths.guiTrace, "trace.jsonl"); + textPath = fullfile(paths.guiTrace, "trace.txt"); + + recorder = createLabKitTraceRecorder( ... + "AppName", "seed_app", ... + "TestName", "PlatformSkeletonTest", ... + "RunId", "seed-run"); + recorder.record("runtime", "session.acquire", "test", ... + struct("sourcePath", "DEVICE", ... + "value", 42)); + recorder.writeJsonl(jsonlPath); + recorder.writeText(textPath); + + testCase.verifyEqual(numel(recorder.events()), 1); + testCase.verifyTrue(isfile(jsonlPath)); + testCase.verifyTrue(isfile(textPath)); + + jsonl = string(fileread(jsonlPath)); + text = string(fileread(textPath)); + testCase.verifyTrue(contains(jsonl, '"schemaVersion":1')); + testCase.verifyTrue(contains(jsonl, '"reason":"test"')); + testCase.verifyTrue(contains(jsonl, '"sourcePath":"[redacted]"')); + testCase.verifyFalse(contains(jsonl, "DEVICE")); + testCase.verifyTrue(contains(text, "component=runtime")); + testCase.verifyTrue(contains(text, "event=session.acquire")); + testCase.verifyFalse(contains(text, "DEVICE")); + end + end +end