readers/alabaster-abstract.js

import * as scran from "scran.js";
import * as bioc from "bioconductor";
import * as eutils from "./utils/extract.js";
import * as futils from "./utils/features.js";
import * as afile from "./abstract/file.js";
import * as jsp from "jaspagate";

/**
 * Any class that satisfies the AlabasterProjectNavigator contract, so called as it is intended to "navigate" an alabaster-formatted object directory.
 * This should provide the following methods:
 * 
 * - `get(path, asBuffer)`, a (possibly async) method that accepts a string `path` containing a relative path to a file inside an object directory. 
 *   This should return a string containing a path to the file on the local filesystem, or a Uint8Array containing the contents of the file if no local filesystem exists.
 *   If `asBuffer = true`, a Uint8Array must be returned.
 * - `exists(path)`, a (possibly async) method that accepts a string `path` containing a relative path to a file inside an object directory. 
 *   This should return a boolean indicating whether `path` exists in the object directory.
 * - `clean(localPath)`, a (possibly async) method that accepts a string containing a local path returned by `get()`.
 *   It should remove any temporary file that was created by `get()` at `localPath`.
 *   This will not be called if `get()` returns a Uint8Array.
 *
 * @typedef AlabasterProjectNavigator
 */

/****************************
 *** Jaspagate interfaces ***
 ****************************/

class AlabasterH5Group extends jsp.H5Group {
    #handle;
    #flush;

    constructor(handle, flush) {
        super();
        this.#handle = handle;
        this.#flush = flush;
    }

    attributes() {
        return this.#handle.attributes;
    }

    readAttribute(attr) {
        let ares = this.#handle.readAttribute(attr);
        return { values: ares.values, shape: ares.shape };
    }

    children() {
        return Array.from(Object.keys(this.#handle.children));
    }

    open(name) {
        let out = this.#handle.open(name);
        if (out instanceof scran.H5Group) {
            return new AlabasterH5Group(out);
        } else {
            return new AlabasterH5DataSet(out);
        }
    }

    close() {}

    _flush() {
        this.#flush();
    }
}

class AlabasterH5DataSet extends jsp.H5DataSet {
    #handle;

    constructor(handle) {
        super();
        this.#handle = handle;
    }

    attributes() {
        return this.#handle.attributes;
    }

    readAttribute(attr) {
        let ares = this.#handle.readAttribute(attr);
        return { values: ares.values, shape: ares.shape };
    }

    type() {
        let type = this.#handle.type;
        if (type instanceof scran.H5StringType) {
            return "String";
        } else if (type instanceof scran.H5CompoundType) {
            return type.members;
        } else {
            return type;
        }
    }

    shape() {
        return this.#handle.shape;
    }

    values() {
        return this.#handle.values;
    }

    close() {}
}

class AlabasterGlobalsInterface extends jsp.GlobalsInterface {
    #navigator;

    constructor(navigator) {
        super();
        this.#navigator = navigator;
    }

    get(path, options = {}) {
        const { asBuffer = false } = options;
        return this.#navigator.get(path, asBuffer);
    }

    exists(path) {
        return this.#navigator.exists(path);
    }

    clean(localPath) {
        this.#navigator.clean(localPath); 
    }

    async h5open(path) {
        let realized = scran.realizeFile(await this.get(path));
        try {
            return new AlabasterH5Group(new scran.H5File(realized.path), realized.flush);
        } catch (e) {
            realized.flush();
            throw e;
        }
    }

    h5close(handle) {
        handle._flush();
    }
}

/*********************
 *** Assay readers ***
 *********************/

class MockMatrix {
    #nrow;
    #ncol;
    #path;

    constructor(nrow, ncol, path) {
        this.#nrow = nrow;
        this.#ncol = ncol;
        this.#path = path;
    }

    _bioconductor_NUMBER_OF_ROWS() {
        return this.#nrow;
    }

    _bioconductor_NUMBER_OF_COLUMNS() {
        return this.#ncol;
    }

    async realize(globals, forceInteger, forceSparse) {
        let metadata = await jsp.readObjectFile(this.#path, globals);
        if (metadata.type == "delayed_array") {
            let contents = await globals.get(jsp.joinPath(this.#path, "array.h5"));
            try {
                let realized = scran.realizeFile(contents);
                try {
                    let handle = new scran.H5File(realized.path);
                    let output = await extract_delayed(handle.open("delayed_array"), this.#path, globals, forceInteger, forceSparse);
                    if (output == null) {
                        throw new Error("currently only supporting bakana-generated log-counts for delayed arrays");
                    }
                    return output;
                } finally {
                    realized.flush();
                }
            } finally {
                await globals.clean(contents);
            }
        } else {
            return extract_matrix(this.#path, metadata, globals, forceInteger, forceSparse);
        }
    }
}

async function extract_matrix(path, metadata, globals, forceInteger, forceSparse) {
    if (metadata.type == "compressed_sparse_matrix") {
        let contents = await globals.get(jsp.joinPath(path, "matrix.h5"));
        try {
            let realized = scran.realizeFile(contents);
            try {
                let fhandle = new scran.H5File(realized.path);
                const name = "compressed_sparse_matrix";

                let dhandle = fhandle.open(name);
                const shape = dhandle.open("shape").values; 
                const layout = dhandle.readAttribute("layout").values[0];

                let out = scran.initializeSparseMatrixFromHdf5Group(realized.path, name, shape[0], shape[1], (layout == "CSR"), { forceInteger });
                return out;
            } finally {
                realized.flush();
            }
        } finally {
            await globals.clean(contents);
        }

    } else if (metadata.type == "dense_array") {
        let contents = await globals.get(jsp.joinPath(path, "array.h5"));
        try {
            let realized = scran.realizeFile(contents);
            try {
                let fhandle = new scran.H5File(realized.path);
                const name = "dense_array";
                let dhandle = fhandle.open(name);
                let transposed = false;
                if (dhandle.attributes.indexOf("transposed") >= 0) {
                    let trans_info = dhandle.readAttribute("transposed");
                    transposed = (trans_info.values[0] != 0);
                }

                return scran.initializeMatrixFromHdf5Dataset(realized.path, name + "/data", { transposed, forceInteger, forceSparse });
            } finally {
                realized.flush();
            }
        } finally {
            await globals.clean(contents);
        }

    } else {
        throw new Error("unknown matrix type '" + metadata.type + "'");
    }
}

async function extract_delayed(handle, path, globals, forceInteger, forceSparse) {
    const dtype = handle.readAttribute("delayed_type").values[0];
    if (dtype === "operation") {
        let optype = handle.readAttribute("delayed_operation").values[0];

        if (optype === "unary arithmetic") {
            const seed = await extract_delayed(handle.open("seed"), path, globals, forceInteger, forceSparse)

            const dhandle = handle.open("value");
            let arg = dhandle.values;
            let along = "row";
            if (dhandle.shape.length == 0) {
                arg = arg[0];
            } else {
                along = (handle.open("along").values[0] === 0 ? "row" : "column");
            }

            return scran.delayedArithmetic(
                seed,
                handle.open("method").values[0],
                arg,
                {
                    right: handle.open("side").values[0] === "right",
                    along: along,
                    inPlace: true
                }
            );

        } else if (optype === "unary math") {
            const seed = await extract_delayed(handle.open("seed"), path, globals, forceInteger, forceSparse)
            const meth = handle.open("method").values[0];
            let base = null;
            if (meth == "log") {
                if ("base" in handle.children) {
                    base = handle.open("base").values[0];
                }
            }
            return scran.delayedMath(
                seed,
                meth,
                {
                    logBase: base,
                    inPlace: true
                }
            );

        } else if (optype == "transpose") {
            const seed = await extract_delayed(handle.open("seed"), path, globals, forceInteger, forceSparse)
            const perm = handle.open("permutation").values;
            if (perm[0] == 1 && perm[1] == 0) {
                return scran.transpose(seed, { inPlace: true });
            } else if (perm[0] == 0 && perm[1] == 1) {
                return seed;
            } else {
                throw new Error("invalid permutation for transposition operation at '" + path + "'");
            }

        } else if (optype == "subset") {
            let mat = await extract_delayed(handle.open("seed"), path, globals, forceInteger, forceSparse)
            const ihandle = handle.open("index");
            if ("0" in ihandle.children) {
                mat = scran.subsetRows(mat, ihandle.open("0").values, { inPlace: true });
            }
            if ("1" in ihandle.children) {
                mat = scran.subsetColumns(mat, ihandle.open("1").values, { inPlace: true });
            }
            return mat;

        } else if (optype == "combine") {
            const shandle = handle.open("seeds");
            let seeds = [];
            try {
                const nchildren = Object.keys(shandle.childen).length;
                for (var c = 0; c < nchildren; c++) {
                    seeds.push(await extract_delayed(shandle.open(String(c)), path, globals, forceInteger, forceSparse));
                }
                if (handle.open("along").values[0] == 0) {
                    return scran.rbind(seeds);
                } else {
                    return scran.cbind(seeds);
                }
            } finally {
                for (const s of seeds) {
                    scran.free(s);
                }
            }

        } else {
            throw new Error("unsupported delayed operation '" + optype + "'");
        }

    } else if (dtype === "array") {
        let atype = handle.readAttribute("delayed_array").values[0];

        if (atype === "custom takane seed array") {
            let index = handle.open("index").values[0];
            let seed_path = jsp.joinPath(path, "seeds", String(index));
            let seed_metadata = await jsp.readObjectFile(seed_path, globals);
            let mat;
            let output;
            try {
                return await extract_matrix(seed_path, seed_metadata, globals, forceInteger, forceSparse); 
            } finally {
                scran.free(mat);
            }

        } else if (atype == "dense array") {
            let is_native = handle.open("native").values[0] != 0;
            return scran.initializeMatrixFromHdf5Dataset(handle.file, handle.name + "/data", { transposed: !is_native, forceInteger, forceSparse });

        } else if (atype == "sparse matrix") {
            const shape = handle.open("shape").values; 
            const is_csr = handle.open("by_column").values[0] == 0;
            return scran.initializeSparseMatrixFromHdf5Group(handle.file, handle.name, shape[0], shape[1], is_csr, { forceInteger });

        } else {
            throw new Error("unsupported delayed array '" + atype + "'");
        }

    } else {
        throw new Error("unsupported delayed type '" + dtype + "'");
    }

    return output;
}

function readMockAssay(nrow, ncol, path, metadata, globals, options) {
    return new MockMatrix(nrow, ncol, path);
}

function readMockReducedDimension(ncol, path, metadata, globals, options) {
    return new MockMatrix(ncol, 2, path);
}

/*************************
 *** Utility functions ***
 *************************/

function apply_over_experiments(se, fun) {
    let main_experiment_name = "";
    let is_sce = se instanceof bioc.SingleCellExperiment;
    if (is_sce && se.mainExperimentName() !== null) {
        main_experiment_name = se.mainExperimentName();
    }

    let output = {};
    output[main_experiment_name] = fun(se);
    if (is_sce) {
        for (const alt of se.alternativeExperimentNames()) {
            if (alt !== main_experiment_name) { // do not clobber the main experiment!
                output[alt] = fun(se.alternativeExperiment(alt));
            }
        }
    }
    return output;
}

function extract_all_features(se) {
    return apply_over_experiments(se, x => x.rowData());
}

function extract_all_assay_names(se) {
    return apply_over_experiments(se, x => x.assayNames());
}

function simplify_List_columns(df) { // avoid the hassle of dealing with List compatibility problems in the rest of bakana.
    for (const k of df.columnNames()) {
        let col = df.column(k);
        if (col instanceof bioc.List) {
            df.setColumn(k, col.toArray(), { inPlace: true });
        }
    }
    return null;
}

/************************
 ******* Dataset ********
 ************************/

/**
 * Dataset stored as a SummarizedExperiment in the **alabaster** format.
 * This is intended as a virtual base class; applications should define subclasses that are tied to a specific {@linkplain AlabasterProjectNavigator} class.
 * Subclasses should define `abbreviate()` and `serialize()` methods, as well as the static `format()` and `unserialize()` methods - 
 * see the [Dataset contract](https://github.com/LTLA/bakana/blob/master/docs/related/custom_readers.md) for more details.
 */
export class AbstractAlabasterDataset {
    #navigator;
    #raw_se;
    #options;

    /**
     * @param {AlabasterProjectNavigator} navigator - A navigator object that describes how to obtain files from the alabaster-formatted object directory.
     */
    constructor(navigator) {
        this.#navigator = navigator;
        this.#options = AbstractAlabasterDataset.defaults();
        this.#raw_se = null;
    }

    /**
     * @return {object} Default options, see {@linkcode AbstractAlabasterDataset#setOptions setOptions} for more details.
     */
    static defaults() {
        return {
            rnaCountAssay: 0, 
            adtCountAssay: 0, 
            crisprCountAssay: 0,
            rnaExperiment: "", 
            adtExperiment: "Antibody Capture", 
            crisprExperiment: "CRISPR Guide Capture",
            primaryRnaFeatureIdColumn: null, 
            primaryAdtFeatureIdColumn: null,
            primaryCrisprFeatureIdColumn: null 
        };
    }

    /**
     * @return {object} Object containing all options used for loading.
     */
    options() {
        return { ...(this.#options) };
    }

    /**
     * @param {object} options - Optional parameters that affect {@linkcode AbstractAlabasterDataset#load load} (but not {@linkcode AbstractAlabasterDataset#summary summary}).
     * @param {string|number} [options.rnaCountAssay] - Name or index of the assay containing the RNA count matrix.
     * @param {string|number} [options.adtCountAssay] - Name or index of the assay containing the ADT count matrix.
     * @param {string|number} [options.crisprCountAssay] - Name or index of the assay containing the CRISPR count matrix.
     * @param {?string} [options.rnaExperiment] - Name of the main/alternative experiment containing gene expression data,
     * as reported in the keys of the `modality_assay_names` of {@linkcode AbstractAlabasterDataset#summary summary}).
     * If `i` is `null` or the name does not exist, it is ignored and no RNA data is assumed to be present.
     * @param {?string} [options.adtExperiment] - Name of the main/alternative experiment containing ADT data,
     * as reported in the keys of the `modality_assay_names` of {@linkcode AbstractAlabasterDataset#summary summary}).
     * If `i` is `null` or the name does not exist, it is ignored and no ADTs are assumed to be present.
     * @param {?string} [options.crisprExperiment] - Name of the main/alternative experiment containing CRISPR guide data,
     * as reported in the keys of the `modality_assay_names` of {@linkcode AbstractAlabasterDataset#summary summary}).
     * If `i` is `null` or the name does not exist, it is ignored and no CRISPR guides are assumed to be present.
     * @param {?(string|number)} [options.primaryRnaFeatureIdColumn] - Name or index of the column of the `features` {@linkplain external:DataFrame DataFrame} that contains the primary feature identifier for gene expression.
     *
     * If `i` is `null` or invalid (e.g., out of range index, unavailable name), it is ignored and the primary identifier is defined as the existing row names.
     * However, if no row names are present in the SummarizedExperiment, no primary identifier is defined.
     * @param {?(string|number)} [options.primaryAdtFeatureIdColumn] - Name or index of the column of the `features` {@linkplain external:DataFrame DataFrame} that contains the primary feature identifier for the ADTs.
     *
     * If `i` is `null` or invalid (e.g., out of range index, unavailable name), it is ignored and the primary identifier is defined as the existing row names.
     * However, if no row names are present in the SummarizedExperiment, no primary identifier is defined.
     * @param {?(string|number)} [options.primaryCrisprFeatureIdColumn] - Name or index of the column of the `features` {@linkplain external:DataFrame DataFrame} that contains the primary feature identifier for the CRISPR guides.
     *
     * If `i` is `null` or invalid (e.g., out of range index, unavailable name), it is ignored and the existing row names (if they exist) are used as the primary identifier.
     * However, if no row names are present in the SummarizedExperiment, no primary identifier is defined.
     */
    setOptions(options) {
        for (const [k, v] of Object.entries(options)) {
            this.#options[k] = v;
        }
    }

    /**
     * Destroy caches if present, releasing the associated memory.
     * This may be called at any time but only has an effect if `cache = true` in {@linkcode AbstractAlabasterDataset#load load} or {@linkcode AbstractAlabasterDataset#summary summary}.
     */
    clear() {
        this.#raw_se = null;
    }

    #create_globals() { 
        return new AlabasterGlobalsInterface(this.#navigator);
    }

    async #populate() {
        if (this.#raw_se === null) {
            this.#raw_se = await jsp.readObject(
                ".",
                null, 
                this.#create_globals(),
                {
                    DataFrame_readNested: false,
                    DataFrame_readMetadata: false,
                    SummarizedExperiment_readAssay: readMockAssay,
                    SummarizedExperiment_readMetadata: false,
                    SingleCellExperiment_readReducedDimension: false
                }
            );
            simplify_List_columns(this.#raw_se.columnData());
            apply_over_experiments(this.#raw_se, y => simplify_List_columns(y.rowData()));
        }
    }

    /**
     * @param {object} [options={}] - Optional parameters.
     * @param {boolean} [options.cache=false] - Whether to cache the intermediate results for re-use in subsequent calls to any methods with a `cache` option.
     * If `true`, users should consider calling {@linkcode AbstractAlabasterDataset#clear clear} to release the memory once this dataset instance is no longer needed.
     * 
     * @return {object} Object containing the per-feature and per-cell annotations.
     * This has the following properties:
     *
     * - `modality_features`: an object where each key is a modality name and each value is a {@linkplain external:DataFrame DataFrame} of per-feature annotations for that modality.
     * - `cells`: a {@linkplain external:DataFrame DataFrame} of per-cell annotations.
     * - `modality_assay_names`: an object where each key is a modality name and each value is an Array containing the names of available assays for that modality.
     *
     * If the main experiment is unnamed, its modality name is set to an empty string.
     * If the main experiment's name is the same as any alternative experiment name, the former will be reported in the returned objects.
     * @async
     */
    async summary({ cache = false } = {}) {
        await this.#populate();

        let output = {
            modality_features: extract_all_features(this.#raw_se),
            cells: this.#raw_se.columnData(),
            modality_assay_names: extract_all_assay_names(this.#raw_se)
        };

        if (!cache) {
            this.clear();
        }
        return output;
    }

    #primary_mapping() {
        return {
            RNA: this.#options.primaryRnaFeatureIdColumn, 
            ADT: this.#options.primaryAdtFeatureIdColumn,
            CRISPR: this.#options.primaryCrisprFeatureIdColumn
        };
    }

    /**
     * @param {object} [options={}] - Optional parameters.
     * @param {boolean} [options.cache=false] - Whether to cache the intermediate results for re-use in subsequent calls to any methods with a `cache` option.
     * If `true`, users should consider calling {@linkcode AbstractAlabasterDataset#clear clear} to release the memory once this dataset instance is no longer needed.
     *
     * @return {object} An object where each key is a modality name and each value is an array (usually of strings) containing the primary feature identifiers for each row in that modality.
     * The contents are the same as the `primary_ids` returned by {@linkcode AbstractAlabasterDataset#load load} but the order of values may be different.
     *
     * If the main experiment is unnamed, its modality name is set to an empty string.
     * If the main experiment's name is the same as any alternative experiment name, the former will be reported in the returned object.
     * @async
     */
    async previewPrimaryIds({ cache = false } = {}) {
        await this.#populate();

        let fmapping = {
            RNA: this.#options.rnaExperiment, 
            ADT: this.#options.adtExperiment, 
            CRISPR: this.#options.crisprExperiment 
        };

        let raw_features = extract_all_features(this.#raw_se);
        let altnames = [];
        if (this.#raw_se instanceof bioc.SingleCellExperiment) {
            altnames = this.#raw_se.alternativeExperimentNames();
        }
        let preview = futils.extractRemappedPrimaryIds(raw_features, altnames, fmapping, this.#primary_mapping());

        if (!cache) {
            this.clear();
        }
        return preview;
    }

    /**
     * @param {object} [options={}] - Optional parameters.
     * @param {boolean} [options.cache=false] - Whether to cache the intermediate results for re-use in subsequent calls to any methods with a `cache` option.
     * If `true`, users should consider calling {@linkcode AbstractAlabasterDataset#clear clear} to release the memory once this dataset instance is no longer needed.
     *
     * @return {object} Object containing the per-feature and per-cell annotations.
     * This has the following properties:
     *
     * - `features`: an object where each key is a modality name and each value is a {@linkplain external:DataFrame DataFrame} of per-feature annotations for that modality.
     * - `cells`: a {@linkplain external:DataFrame DataFrame} containing per-cell annotations.
     * - `matrix`: a {@linkplain external:MultiMatrix MultiMatrix} containing one {@linkplain external:ScranMatrix ScranMatrix} per modality.
     * - `primary_ids`: an object where each key is a modality name and each value is an array (usually of strings) containing the primary feature identifiers for each row in that modality.
     *
     * Modality names are guaranteed to be one of `"RNA"`, `"ADT"` or `"CRISPR"`.
     * We assume that the instance already contains an appropriate mapping from the observed feature types to each expected modality,
     * either from the {@linkcode AbstractAlabasterDataset#defaults defaults} or with {@linkcode AbstractAlabasterDataset#setOptions setOptions}.
     *
     * @async
     */
    async load({ cache = false } = {}) {
        await this.#populate();

        let output = { 
            matrix: new scran.MultiMatrix,
            features: {},
            cells: this.#raw_se.columnData()
        };

        let mapping = { 
            RNA: { exp: this.#options.rnaExperiment, assay: this.#options.rnaCountAssay },
            ADT: { exp: this.#options.adtExperiment, assay: this.#options.adtCountAssay },
            CRISPR: { exp: this.#options.crisprExperiment, assay: this.#options.crisprCountAssay }
        };

        let experiments_by_name = apply_over_experiments(this.#raw_se, x => x);
        try {
            for (const [k, v] of Object.entries(mapping)) {
                if (v.exp === null || !(v.exp in experiments_by_name)) {
                    continue;
                }
                let chosen_se = experiments_by_name[v.exp];
                let loaded = await chosen_se.assay(v.assay).realize(this.#create_globals(), /* forceInteger = */ true, /* forceSparse = */ true);
                output.matrix.add(k, loaded);
                output.features[k] = chosen_se.rowData();
            }

            output.primary_ids = futils.extractPrimaryIds(output.features, this.#primary_mapping());

        } catch (e) {
            scran.free(output.matrix);
            throw e;
        }

        if (!cache) {
            this.clear();
        }
        return output;
    }
}

/***********************
 ******* Result ********
 ***********************/

/**
 * Pre-computed analysis results stored as a SummarizedExperiment object (or one of its subclasses) in the **ArtifactDB** format.
 * This is intended as a virtual base class; applications should define subclasses that are tied to a specific {@linkplain AlabasterProjectNavigator} class.
 */
export class AbstractAlabasterResult {
    #navigator;
    #raw_se;
    #options;

    /**
     * @param {AlabasterNavigator} navigator - A navigator object that describes how to obtain files from an alabaster-formatted object directory.
     */
    constructor(navigator) {
        this.#navigator = navigator;
        this.#options = AbstractAlabasterResult.defaults();
        this.#raw_se = null;
    }

    /**
     * @return {object} Default options, see {@linkcode AbstractAlabasterResults#setOptions setOptions} for more details.
     */
    static defaults() {
        return { 
            primaryAssay: 0,
            isPrimaryNormalized: true,
            reducedDimensionNames: null
        };
    }

    /**
     * @return {object} Object containing all options used for loading.
     */
    options() {
        return { ...(this.#options) };
    }

    /**
     * @param {object} options - Optional parameters that affect {@linkcode AbstractAlabasterResult#load load} (but not {@linkcode AbstractAlabasterResult#summary summary}.
     * @param {object|string|number} [options.primaryAssay] - Assay containing the relevant data for each modality.
     *
     * - If a string, this is used as the name of the assay across all modalities.
     * - If a number, this is used as the index of the assay across all modalities.
     * - If any object, the key should be the name of a modality and the value may be either a string or number specifying the assay to use for that modality.
     *   Modalities absent from this object will not be loaded.
     * @param {object|boolean} [options.isPrimaryNormalized] - Whether or not the assay for a particular modality has already been normalized.
     *
     * - If a boolean, this is used to indicate normalization status of assays across all modalities.
     *   If `false`, that modality's assay is assumed to contain count data and is subjected to library size normalization. 
     * - If any object, the key should be the name of a modality and the value should be a boolean indicating whether that modality's assay has been normalized.
     *   Modalities absent from this object are assumed to have been normalized.
     * @param {?Array} [options.reducedDimensionNames] - Array of names of the reduced dimensions to load.
     * If `null`, all reduced dimensions found in the file are loaded.
     */
    setOptions(options) {
        // Cloning to avoid pass-by-reference links.
        for (const [k, v] of Object.entries(options)) {
            this.#options[k] = bioc.CLONE(v);
        }
    }

    /**
     * Destroy caches if present, releasing the associated memory.
     * This may be called at any time but only has an effect if `cache = true` in {@linkcode AbstractAlabasterResult#load load} or {@linkcode AbstractAlabasterResult#summary summary}.
     */
    clear() {
        this.#raw_se = null;
    }

    #create_globals() { 
        return new AlabasterGlobalsInterface(this.#navigator);
    }

    async #populate() {
        if (this.#raw_se === null) {
            this.#raw_se = await jsp.readObject(
                ".",
                null, 
                this.#create_globals(),
                {
                    DataFrame_readNested: false,
                    DataFrame_readMetadata: false,
                    SummarizedExperiment_readAssay: readMockAssay,
                    SummarizedExperiment_readMetadata: false,
                    SingleCellExperiment_readReducedDimension: readMockReducedDimension
                }
            );
            simplify_List_columns(this.#raw_se.columnData());
            apply_over_experiments(this.#raw_se, y => simplify_List_columns(y.rowData()));
        }
    }

    /**
     * @param {object} [options={}] - Optional parameters.
     * @param {boolean} [options.cache=false] - Whether to cache the results for re-use in subsequent calls to this method or {@linkcode AbstractAlabasterResult#load load}.
     * If `true`, users should consider calling {@linkcode AbstractAlabasterResult#clear clear} to release the memory once this dataset instance is no longer needed.
     * 
     * @return {object} Object containing the per-feature and per-cell annotations.
     * This has the following properties:
     *
     * - `modality_features`: an object where each key is a modality name and each value is a {@linkplain external:DataFrame DataFrame} of per-feature annotations for that modality.
     * - `cells`: a {@linkplain external:DataFrame DataFrame} of per-cell annotations.
     * - `modality_assay_names`: an object where each key is a modality name and each value is an Array containing the names of available assays for that modality.
     *    Unnamed assays are represented as `null` names.
     * - `reduced_dimension_names`: an Array of strings containing names of dimensionality reduction results.
     *
     * If the main experiment is unnamed, its modality name is set to an empty string.
     * If the main experiment's name is the same as any alternative experiment name, the former will be reported in the returned objects.
     * @async 
     */
    async summary({ cache = false } = {}) {
        await this.#populate();

        let output = {
            modality_features: extract_all_features(this.#raw_se),
            cells: this.#raw_se.columnData(),
            modality_assay_names: extract_all_assay_names(this.#raw_se),
            reduced_dimension_names: []
        };

        if (this.#raw_se instanceof bioc.SingleCellExperiment) {
            output.reduced_dimension_names = this.#raw_se.reducedDimensionNames();
        }

        if (!cache) {
            this.clear();
        }
        return output;
    }

    /**
     * @param {object} [options={}] - Optional parameters.
     * @param {boolean} [options.cache=false] - Whether to cache the results for re-use in subsequent calls to this method or {@linkcode AbstractAlabasterResult#summary summary}.
     * If `true`, users should consider calling {@linkcode AbstractAlabasterResult#clear clear} to release the memory once this dataset instance is no longer needed.
     *
     * @return {object} Object containing the per-feature and per-cell annotations.
     * This has the following properties:
     *
     * - `features`: an object where each key is a modality name and each value is a {@linkplain external:DataFrame DataFrame} of per-feature annotations for that modality.
     * - `cells`: a {@linkplain external:DataFrame DataFrame} containing per-cell annotations.
     * - `matrix`: a {@linkplain external:MultiMatrix MultiMatrix} containing one {@linkplain external:ScranMatrix ScranMatrix} per modality.
     * - `reduced_dimensions`: an object containing the dimensionality reduction results.
     *   Each value is an array of arrays, where each inner array contains the coordinates for one dimension.
     *
     * If the main experiment is unnamed, its modality name is set to an empty string.
     * If the main experiment's name is the same as any alternative experiment name, the former will be reported in the returned objects.
     * @async
     */
    async load({ cache = false } = {}) {
        await this.#populate();

        let output = { 
            matrix: new scran.MultiMatrix,
            features: {},
            cells: this.#raw_se.columnData(),
            reduced_dimensions: {}
        };

        if (this.#raw_se instanceof bioc.SingleCellExperiment) {
            let chosen_rd = this.#options.reducedDimensionNames;
            if (chosen_rd === null) {
                chosen_rd = this.#raw_se.reducedDimensionNames();
            }
            for (const k of chosen_rd) {
                let current = await this.#raw_se.reducedDimension(k).realize(this.#create_globals(), /* forceInteger = */ false, /* forceSparse = */ false);
                try {
                    let collected = [];
                    let ncol = current.numberOfColumns();
                    for (var c = 0; c < ncol; c++) {
                        collected.push(current.column(c));
                    }
                    output.reduced_dimensions[k] = collected;
                } finally {
                    scran.free(current);
                }
            }
        }

        // Now fetching the assay matrix.
        const experiments_by_name = apply_over_experiments(this.#raw_se, x => x);
        try {
            for (const [name, chosen_se] of Object.entries(experiments_by_name)) {
                let curassay = this.#options.primaryAssay;
                if (typeof curassay == "object") {
                    if (name in curassay) {
                        curassay = curassay[name];
                    } else {
                        continue;
                    }
                }

                let curnormalized = this.#options.isPrimaryNormalized;
                if (typeof curnormalized == "object") {
                    if (name in curnormalized) {
                        curnormalized = curnormalized[name];
                    } else {
                        curnormalized = true;
                    }
                }

                let loaded = await chosen_se.assay(curassay).realize(this.#create_globals(), /* forceInteger= */ !curnormalized, /* forceSparse = */ true);
                output.matrix.add(name, loaded);

                if (!curnormalized) {
                    let normed = scran.normalizeCounts(loaded, { allowZeros: true });
                    output.matrix.add(name, normed);
                }

                output.features[name] = chosen_se.rowData();
            }

        } catch (e) {
            scran.free(output.matrix);
            throw e;
        }

        if (!cache) {
            this.clear();
        }
        return output;
    }
}