SummarizedExperiment.js

import * as generics from "./AllGenerics.js";
import * as ann from "./Annotated.js";
import * as df from "./DataFrame.js";
import * as utils from "./utils.js";
import * as cutils from "./clone-utils.js";
import * as il from "./InternalList.js";

/**
 * A SummarizedExperiment contains zero or more assays, consisting of multi-dimensional arrays (usually matrices) of experimental data,
 * as well as {@linkplain DataFrame}s containing further annotations on the rows or columns of those arrays.
 * The SummarizedExperiment class defines methods for the following generics:
 * 
 * - {@linkcode NUMBER_OF_ROWS}
 * - {@linkcode NUMBER_OF_COLUMNS}
 * - {@linkcode SLICE_2D}
 * - {@linkcode COMBINE_ROWS}
 * - {@linkcode COMBINE_COLUMNS}
 * - {@linkcode CLONE}
 *
 * Assays are expected to provide methods for the following generics:
 *
 * - {@linkcode NUMBER_OF_ROWS}
 * - {@linkcode NUMBER_OF_COLUMNS}
 * - {@linkcode SLICE_2D}
 * - {@linkcode COMBINE_ROWS}
 * - {@linkcode COMBINE_COLUMNS}
 * - {@linkcode CLONE}
 *
 * @extends Annotated
 */
export class SummarizedExperiment extends ann.Annotated {
    /**
     * @param {Object|Map} assays - Object or Map where keys are the assay names and values are multi-dimensional arrays of experimental data.
     * All arrays should have the same number of rows and columns.
     * @param {Object} [options={}] - Optional parameters.
     * @param {?Array} [options.assayOrder=null] - Array of strings specifying the ordering of the assays.
     * If non-`null`, this should have the same values as the keys of `assays`.
     * If `null`, an arbitrary ordering is obtained from `assays`.
     * @param {?DataFrame} [options.rowData=null] - Data frame of row annotations.
     * If non-`null`, this should have a number of rows equal to the number of rows in each entry of `assays`.
     * If `null`, an empty {@linkplain DataFrame} is automatically created.
     * @param {?DataFrame} [options.columnData=null] - Data frame of column annotations.
     * If non-`null`, this should have a number of columns equal to the number of columns in each entry of `assays`.
     * If `null`, an empty {@linkplain DataFrame} is automatically created.
     * @param {?Array} [options.rowNames=null] - Array of strings of length equal to the number of rows in the `assays`, containing row names.
     * Alternatively `null`, if no row names are present.
     * @param {?Array} [options.columnNames=null] - Array of strings of length equal to the number of columns in the `assays`, containing column names.
     * Alternatively `null`, if no column names are present.
     * @param {Object|Map} [options.metadata={}] - Object or Map containing arbitrary metadata as key-value pairs.
     */
    constructor(assays, { assayOrder = null, rowData = null, columnData = null, rowNames = null, columnNames = null, metadata = {} } = {}) {
        if (arguments.length == 0) {
            super();
            return;
        }

        super(metadata);

        // Check the assays.
        try {
            this._assays = new il.InternalList(assays, assayOrder);
        } catch (e) {
            throw new Error("failed to initialize assay list for this SummarizedExperiment; " + e.message, { cause: e });
        }

        let nrows = null;
        let ncols = null;
        for (const k of this._assays.names()) {
            let current = this._assays.entry(k);
            let nr = generics.NUMBER_OF_ROWS(current);
            let nc = generics.NUMBER_OF_COLUMNS(current);
            if (nrows == null) {
                nrows = nr;
                ncols = nc;
            } else if (nrows !== nr || ncols !== nc) {
                throw new Error("expected all assays in 'assays' to have the same number of rows and columns");
            }
        }

        // Check the rowData.
        if (rowData === null) {
            if (nrows == null){
                throw new Error("'rowData' must be specified if 'assays' is empty");
            }
            rowData = new df.DataFrame({}, { numberOfRows: nrows });
        } else {
            if (nrows !== null && nrows !== generics.LENGTH(rowData)) {
                throw new Error("'rowData' should be equal to the number of rows in each 'assays'");
            }
        }
        this._rowData = rowData;

        // Check the columnData.
        if (columnData === null) {
            if (ncols == null){
                throw new Error("'columnData' must be specified if 'assays' is empty");
            }
            columnData = new df.DataFrame({}, { numberOfRows: ncols });
        } else {
            if (ncols !== null && ncols !== generics.LENGTH(columnData)) {
                throw new Error("'columnData' should be equal to the number of columns in each 'assays'");
            }
        }
        this._columnData = columnData;

        // Checking the names.
        if (rowNames != null) {
            utils.checkNamesArray(rowNames, "'rowNames'", this._rowData.numberOfRows(), "the number of rows in each 'assays'");
        }
        this._rowNames = rowNames;

        if (columnNames != null) {
            utils.checkNamesArray(columnNames, "'columnNames'", this._columnData.numberOfRows(), "the number of columns in each 'assays'");
        }
        this._columnNames = columnNames;
    }

    static className = "SummarizedExperiment";

    /**************************************************************************
     **************************************************************************
     **************************************************************************/

    /**
     * @return {Array} Array of assay names.
     */
    assayNames() {
        return this._assays.names();
    }

    /**
     * @return {number} Number of assays.
     */
    numberOfAssays() {
        return this._assays.numberOfEntries();
    }

    /**
     * @param {string|number} i - Assay to retrieve, either by name or index.
     * @return {*} The contents of assay `i` as an multi-dimensional array-like object.
     */
    assay(i) {
        let output;
        try {
            output = this._assays.entry(i);
        } catch (e) {
            throw new Error("failed to retrieve the specified assay from this " + this.constructor.className + "; " + e.message, { cause: e });
        }
        return output;
    }

    /**
     * @return {DataFrame} Data frame of row data, with one row per row in this SummarizedExperiment.
     */
    rowData() {
        return this._rowData;
    }

    /**
     * @return {number} Number of rows in this SummarizedExperiment.
     */
    numberOfRows() {
        return this._rowData.numberOfRows();
    }

    /**
     * @return {?Array} Array of strings containing row names, or `null` if no row names are available.
     */
    rowNames() {
        return this._rowNames;
    }

    /**
     * @return {DataFrame} Data frame of column data, with one row per column in this SummarizedExperiment.
     */
    columnData() {
        return this._columnData;
    }

    /**
     * @return {number} Number of columns in this SummarizedExperiment.
     */
    numberOfColumns() {
        return this._columnData.numberOfRows();
    }

    /**
     * @return {?Array} Array of strings containing column names, or `null` if no column names are available.
     */
    columnNames() {
        return this._columnNames;
    }

    /**************************************************************************
     **************************************************************************
     **************************************************************************/

    /**
     * @param {string|number} i - Identity of the assay to add, either by name or index.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment after removing the specified assay.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    removeAssay(i, { inPlace = false } = {}) {
        let target = cutils.setterTarget(this, inPlace);
        try {
            target._assays = target._assays.delete(i, { inPlace });
        } catch (e) {
            throw new Error("failed to remove assay " + (typeof i == "string" ? "'" + i + "'" : String(i)) + " from this " + this.constructor.className + "; " + e.message, { cause: e });
        }
        return target;
    }

    /**
     * @param {string|number} i - Identity of the assay to add, either by name or index.
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment after removing the specified assay.
     */
    $removeAssay(i) {
        return this.removeAssay(i, { inPlace: true });
    }

    /**
     * @param {string|number} i - Identity of the assay to add, either by name or index.
     * - If `i` is a number, the assay at the specified index is replaced.
     *   `i` should be non-negative and less than the number of assays.
     * - If `i` is a string, any assay with the same name is replaced.
     *   If no such assay exists, a new assay is appended to the list of assays.
     * @param {*} value - Multi-dimensional array-like object to set/add as the assay.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} A SummarizedExperiment with modified assays.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setAssay(i, value, { inPlace = false } = {}) {
        if (generics.NUMBER_OF_ROWS(value) !== this.numberOfRows() || generics.NUMBER_OF_COLUMNS(value) !== this.numberOfColumns()) {
            throw new Error("expected 'value' to have the same dimensions as this 'SummarizedExperiment'");
        }
        let target = cutils.setterTarget(this, inPlace);
        target._assays = target._assays.set(i, value, { inPlace });
        return target;
    }

    /**
     * @param {string|number} i - Identity of the assay to add, either by name or index.
     * - If `i` is a number, the assay at the specified index is replaced.
     *   `i` should be non-negative and less than the number of assays.
     * - If `i` is a string, any assay with the same name is replaced.
     *   If no such assay exists, a new assay is appended to the list of assays.
     * @param {*} value - Multi-dimensional array-like object to set/add as the assay.
     *
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with modified assays.
     */
    $setAssay(i, value) {
        return this.setAssay(i, value, { inPlace: true });
    }

    /**
     * @param {Array} names - Array of strings containing the assay names.
     * This should be of the same length as the number of assays and contain unique values.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified assay names.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setAssayNames(names, { inPlace = false } = {}) {
        let target = cutils.setterTarget(this, inPlace);
        try {
            target._assays = target._assays.setNames(names, { inPlace });
        } catch (e) {
            throw new Error("failed to set the assay names for this " + this.constructor.className + "; " + e.message, { cause: e });
        }
        return target;
    }

    /**
     * @param {Array} names - Array of strings containing the assay names.
     * This should be of the same length as the number of assays and contain unique values.
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with modified assay names.
     */
    $setAssayNames(names) {
        return this.setAssayNames(names, { inPlace: true });
    }

    /**
     * @param {Array} i - Array of strings or indices specifying the assays to retain in the slice.
     * This should refer to unique assay names.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with sliced assays.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    sliceAssays(i, { inPlace = false } = {}) {
        let target = cutils.setterTarget(this, inPlace);
        try {
            target._assays = this._assays.slice(i, { inPlace });
        } catch (e) {
            throw new Error("failed to slice the assays for this " + this.constructor.className + "; " + e.message, { cause: e });
        }
        return target;
    }

    /**
     * @param {Array} i - Array of strings or indices specifying the assays to retain in the slice.
     * This should refer to unique assay names.
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with sliced assays.
     */
    $sliceAssays(i) {
        return this.sliceAssays(i, { inPlace: true });
    }

    /**
     * @param {DataFrame} value - Data frame containing the row annotations.
     * This should have one row for each row of this SummarizedExperiment.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified row data.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setRowData(value, { inPlace = false } = {}) {
        if (!(value instanceof df.DataFrame)) {
            throw new Error("'value' should be a DataFrame");
        }

        if (value.numberOfRows() !== this.numberOfRows()) {
            throw new Error("expected 'value' to have the same number of rows as this 'SummarizedExperiment'");
        }

        let target = cutils.setterTarget(this, inPlace);
        target._rowData = value;
        return target;
    }

    /**
     * @param {DataFrame} value - Data frame containing the row annotations.
     * This should have one row for each row of this SummarizedExperiment.
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with modified row data.
     */
    $setRowData(value) {
        return this.setRowData(value, { inPlace: true });
    }

    /**
     * @param {DataFrame} value - Data frame containing the column annotations.
     * This should have one row for each columns of this SummarizedExperiment.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified column data.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setColumnData(value, { inPlace = false } = {}) {
        if (!(value instanceof df.DataFrame)) {
            throw new Error("'value' should be a DataFrame");
        }

        if (value.numberOfRows() !== this.numberOfColumns()) {
            throw new Error("expected 'value' to have the same number of rows as the number of columns of this 'SummarizedExperiment'");
        }

        let target = cutils.setterTarget(this, inPlace);
        target._columnData = value;
        return target;
    }

    /**
     * @param {DataFrame} value - Data frame containing the column annotations.
     * This should have one row for each columns of this SummarizedExperiment.
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with modified column data.
     */
    $setColumnData(value) {
        return this.setColumnData(value, { inPlace: true });
    }

    /**
     * @param {Array} names - Array of strings of length equal to the number of rows in this SummarizedExperiment, containing row names.
     * Alternatively `null`, to remove all row names.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified row names.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setRowNames(names, { inPlace = false } = {}) {
        if (names !== null) {
            utils.checkNamesArray(names, "replacement 'names'", this.numberOfRows(), "'numberOfRows()'");
        }

        let target = cutils.setterTarget(this, inPlace);
        target._rowNames = names;
        return target;
    }

    /**
     * @param {Array} names - Array of strings of length equal to the number of rows in this SummarizedExperiment, containing row names.
     * Alternatively `null`, to remove all row names.
     *
     * @return {SummarizedExperiment} A reference to this SummarizedExperiment with modified row names.
     */
    $setRowNames(names) {
        return this.setRowNames(names, { inPlace: true });
    }

    /**
     * @param {Array} names - Array of strings of length equal to the number of columns in this SummarizedExperiment, containing column names.
     * Alternatively `null`, to remove all column names.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified column names.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    setColumnNames(names, { inPlace = false } = {}) {
        if (names !== null) {
            utils.checkNamesArray(names, "replacement 'names'", this.numberOfColumns(), "'numberOfColumns()'");
        }

        let target = cutils.setterTarget(this, inPlace);
        target._columnNames = names;
        return target;
    }

    /**
     * @param {Array} names - Array of strings of length equal to the number of columns in this SummarizedExperiment, containing column names.
     * Alternatively `null`, to remove all column names.
     * @param {Object} [options={}] - Optional parameters.
     * @param {boolean} [options.inPlace=false] - Whether to mutate this SummarizedExperiment instance in place.
     * If `false`, a new instance is returned.
     *
     * @return {SummarizedExperiment} The SummarizedExperiment with modified column names.
     * If `inPlace = true`, this is a reference to the current instance, otherwise a new instance is created and returned.
     */
    $setColumnNames(names) {
        return this.setColumnNames(names, { inPlace: true });
    }

    /**************************************************************************
     **************************************************************************
     **************************************************************************/

    _bioconductor_NUMBER_OF_ROWS() {
        return this.numberOfRows();
    }

    _bioconductor_NUMBER_OF_COLUMNS() {
        return this.numberOfColumns();
    }

    _bioconductor_SLICE_2D(output, rows, columns, { allowView = false }) {
        output._assays = this._assays.apply(v => generics.SLICE_2D(v, rows, columns, { allowView }));

        if (rows !== null) {
            output._rowData = generics.SLICE(this._rowData, rows, { allowView });
            output._rowNames = (this._rowNames == null ? null : generics.SLICE(this._rowNames, rows, { allowView }));
        } else {
            output._rowData = this._rowData;
            output._rowNames = this._rowNames;
        }

        if (columns !== null) {
            output._columnData = generics.SLICE(this._columnData, columns, { allowView });
            output._columnNames = (this._columnNames == null ? null : generics.SLICE(this._columnNames, columns, { allowView }));
        } else {
            output._columnData = this._columnData;
            output._columnNames = this._columnNames;
        }

        output._metadata = this._metadata;
        return;
    }

    _bioconductor_COMBINE_ROWS(output, objects) {
        output._assays = il.InternalList.parallelCombine(objects.map(x => x._assays), generics.COMBINE_ROWS);

        let all_dfs = objects.map(x => x._rowData);
        output._rowData = generics.COMBINE(all_dfs);

        let all_n = objects.map(x => x._rowNames);
        let all_l = objects.map(x => x.numberOfRows());
        output._rowNames = utils.combineNames(all_n, all_l);

        output._columnData = this._columnData;
        output._columnNames = this._columnNames;
        output._metadata = this._metadata;
    }

    _bioconductor_COMBINE_COLUMNS(output, objects) {
        output._assays = il.InternalList.parallelCombine(objects.map(x => x._assays), generics.COMBINE_COLUMNS);

        let all_dfs = objects.map(x => x._columnData);
        output._columnData = generics.COMBINE(all_dfs);

        let all_n = objects.map(x => x._columnNames);
        let all_l = objects.map(x => x.numberOfColumns());
        output._columnNames = utils.combineNames(all_n, all_l);

        output._rowData = this._rowData;
        output._rowNames = this._rowNames;
        output._metadata = this._metadata;
    }

    _bioconductor_CLONE(output, { deepCopy = true }) {
        super._bioconductor_CLONE(output, { deepCopy });

        output._assays = cutils.cloneField(this._assays, deepCopy);
        output._rowData = cutils.cloneField(this._rowData, deepCopy);
        output._rowNames = cutils.cloneField(this._rowNames, deepCopy);

        output._columnData = cutils.cloneField(this._columnData, deepCopy);
        output._columnNames = cutils.cloneField(this._columnNames, deepCopy);
        return;
    }
}