steps/adt_quality_control.js

import * as scran from "scran.js"; 
import * as utils from "./utils/general.js";
import * as inputs_module from "./inputs.js";

export const step_name = "adt_quality_control";

/**
 * Results of computing per-cell ADT-derived QC metrics,
 * see [here](https://kanaverse.github.io/scran.js/PerCellAdtQcMetricsResults.html) for details.
 *
 * @external PerCellAdtQcMetricsResults
 */

/**
 * Suggested filters for the ADT-derived QC metrics,
 * see [here](https://kanaverse.github.io/scran.js/SuggestAdtQcFiltersResults.html) for details.
 *
 * @external SuggestAdtQcFiltersResults
 */

/**
 * This step applies quality control on the ADT count matrix.
 * Specifically, it computes the QC metrics and filtering thresholds, 
 * wrapping the [`perCellAdtQcMetrics`](https://kanaverse.github.io/scran.js/global.html#perCellAdtQcMetrics)
 * and [`suggestAdtQcFilters`](https://kanaverse.github.io/scran.js/global.html#suggestAdtQcFilters) functions
 * from [**scran.js**](https://github.com/kanaverse/scran.js).
 * Note that the actual filtering is done by {@linkplain CellFilteringState}.
 *
 * Methods not documented here are not part of the stable API and should not be used by applications.
 * @hideconstructor
 */
export class AdtQualityControlState {
    #inputs;
    #cache;
    #parameters;

    constructor(inputs, parameters = null, cache = null) {
        if (!(inputs instanceof inputs_module.InputsState)) {
            throw new Error("'inputs' should be a State object from './inputs.js'");
        }
        this.#inputs = inputs;

        this.#parameters = (parameters === null ? {} : parameters);
        this.#cache = (cache === null ? {} : cache);
        this.changed = false;
    }

    free() {
        utils.freeCache(this.#cache.metrics);
        utils.freeCache(this.#cache.filters);
        utils.freeCache(this.#cache.metrics_buffer);
        utils.freeCache(this.#cache.keep_buffer);
    }

    /***************************
     ******** Getters **********
     ***************************/

    valid() {
        let input = this.#inputs.fetchCountMatrix();
        return input.has("ADT");
    }

    /**
     * @return {object} Object containing the parameters.
     */
    fetchParameters() {
        return { ...this.#parameters }; // avoid pass-by-reference links.
    }

    /**
     * @return {external:SuggestAdtQcFiltersResults} Result of filtering on the ADT-derived QC metrics.
     * This is available after running {@linkcode AdtQualityControlState#compute compute}.
     */
    fetchFilters() {
        return this.#cache.filters;
    }

    /**
     * @return {Uint8WasmArray} Buffer containing a vector of length equal to the number of cells,
     * where each element is truthy if the corresponding cell is to be retained after filtering.
     * This is available after running {@linkcode AdtQualityControlState#compute compute}.
     */
    fetchKeep() {
        return this.#cache.keep_buffer;
    }

    /**
     * @return {external:PerCellAdtQcMetricsResults} ADT-derived QC metrics,
     * available after running {@linkcode AdtQualityControlState#compute compute}.
     */
    fetchMetrics() {
        return this.#cache.metrics;
    }

    /****************************
     ******** Defaults **********
     ****************************/

    /**
     * @return {object} Object containing default parameters,
     * see the `parameters` argument in {@linkcode AdtQualityControlState#compute compute} for details.
     */
    static defaults() {
        return {
            guess_ids: true,
            tag_id_column: null,
            igg_prefix: "IgG",

            filter_strategy: "automatic", 
            nmads: 3,
            min_detected_drop: 0.1,

            detected_threshold: 0,
            igg_threshold: 1
        };
    }

    static #configureFeatureParameters(lower_igg, annotations) {
        let counter = val => {
            let n = 0;
            val.forEach(x => {
                if (x.toLowerCase().startsWith(lower_igg)) {
                    n++;
                }
            });
            return n;
        };

        let best_key = null;
        let best = 0;

        let rn = annotations.rowNames();
        if (rn !== null) {
            best = counter(rn);
        }

        for (const key of annotations.columnNames()) {
            let latest = counter(annotations.column(key));
            if (latest > best) {
                best_key = key;
                best = latest;
            }
        }

        return best_key;
    }

    /***************************
     ******** Compute **********
     ***************************/

    /**
     * This method should not be called directly by users, but is instead invoked by {@linkcode runAnalysis}.
     * 
     * @param {object} parameters - Parameter object, equivalent to the `adt_quality_control` property of the `parameters` of {@linkcode runAnalysis}.
     * @param {boolean} [parameters.guess_ids] - Automatically choose feature-based parameters based on the feature annotations. 
     * Specifically, `tag_id_column` is set to the column with the most matches to `igg_prefix`.
     * @param {?(string|number)} [parameters.tag_id_column] - Name or index of the column of the feature annotations that contains the tag identifiers.
     * If `null`, the row names are used.
     * Ignored if `guess_ids = true`.
     * @param {?string} [parameters.igg_prefix]  - Prefix of the identifiers for isotype controls.
     * If `null`, no prefix-based identification is performed.
     * @param {string} [parameters.filter_strategy] - Strategy for defining a filter threshold for the QC metrics.
     * This can be `"automatic"` or `"manual"`.
     * @param {number} [parameters.nmads] - Number of MADs to use for automatically selecting the filter threshold for each metric.
     * Only used when `filter_strategy = "automatic"`.
     * @param {number} [parameters.min_detected_drop] - Minimum proportional drop in the number of detected features before a cell is to be considered low-quality.
     * Only used when `filter_strategy = "automatic"`.
     * @param {number} [parameters.detected_threshold] - Manual threshold on the detected number of features for each cell.
     * Cells are only retained if the detected number is equal to or greater than this threshold.
     * Only used when `filter_strategy = "manual"`.
     * @param {number} [parameters.igg_threshold] - Manual threshold on the isotype control totals for each cell.
     * Cells are only retained if their totals are less than or equal to this threshold.
     * Only used when `filter_strategy = "manual"`.
     *
     * @return The object is updated with the new results.
     */
    compute(parameters) {
        parameters = utils.defaultizeParameters(parameters, AdtQualityControlState.defaults(), [ "automatic" ]);
        this.changed = false;

        // Some back-compatibility here.
        if (typeof parameters.guess_ids === "undefined") {
            if ("automatic" in parameters) {
                parameters.guess_ids = parameters.automatic;
            } else {
                parameters.guess_ids = true;
            }
        }

        if (
            this.#inputs.changed || 
            parameters.guess_ids !== this.#parameters.guess_ids ||
            parameters.igg_prefix !== this.#parameters.igg_prefix ||
            (!parameters.guess_ids && parameters.tag_id_column !== this.#parameters.tag_id_column)
        ) {
            utils.freeCache(this.#cache.metrics);

            if (this.valid()) {
                var tag_info = this.#inputs.fetchFeatureAnnotations()["ADT"];
                var subsets = utils.allocateCachedArray(tag_info.numberOfRows(), "Uint8Array", this.#cache, "metrics_buffer");
                subsets.fill(0);

                if (parameters.igg_prefix !== null) {
                    var lower_igg = parameters.igg_prefix.toLowerCase();
                    let key = parameters.tag_id_column;
                    if (parameters.guess_ids) {
                        key = AdtQualityControlState.#configureFeatureParameters(lower_igg, tag_info);
                    }

                    let val = (key == null ? tag_info.rowNames() : tag_info.column(key));
                    if (val !== null) {
                        var sub_arr = subsets.array();
                        val.forEach((x, i) => { 
                            if (x.toLowerCase().startsWith(lower_igg)) {
                                sub_arr[i] = 1;                        
                            }
                        });
                    }
                }

                var mat = this.#inputs.fetchCountMatrix().get("ADT");
                this.#cache.metrics = scran.perCellAdtQcMetrics(mat, [subsets]);
                this.changed = true;
            } else {
                delete this.#cache.metrics;
            }
        }

        if (this.changed || 
            parameters.filter_strategy !== this.#parameters.filter_strategy ||
            parameters.nmads !== this.#parameters.nmads || 
            parameters.min_detected_drop !== this.#parameters.min_detected_drop ||
            parameters.detected_threshold !== this.#parameters.detected_threshold ||
            parameters.igg_threshold !== this.#parameters.igg_threshold
        ) {
            utils.freeCache(this.#cache.filters);

            if (this.valid()) {
                let block = this.#inputs.fetchBlock();

                if (parameters.filter_strategy === "automatic") {
                    this.#cache.filters = scran.suggestAdtQcFilters(this.#cache.metrics, { numberOfMADs: parameters.nmads, block: block });
                } else if (parameters.filter_strategy === "manual") {
                    let block_levels = this.#inputs.fetchBlockLevels();
                    this.#cache.filters = scran.emptySuggestAdtQcFiltersResults(1, block_levels === null ? 1 : block_levels.length);
                    this.#cache.filters.detected({ copy: false }).fill(parameters.detected_threshold);
                    this.#cache.filters.subsetSum(0, { copy: false }).fill(parameters.igg_threshold);
                } else {
                    throw new Error("unknown ADT QC filtering strategy '" + filter_strategy + "'");
                }

                var keep = utils.allocateCachedArray(this.#cache.metrics.numberOfCells(), "Uint8Array", this.#cache, "keep_buffer");
                this.#cache.filters.filter(this.#cache.metrics, { block: block, buffer: keep });
                this.changed = true;
            } else {
                delete this.#cache.filters;
            }
        }

        this.#parameters = parameters;
        return;
    }
}