mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-06 05:58:11 +08:00
experimental re-embedding (#1186)
* first cut at re-embedding route and back-end support * update and expand config route tests * add scanpy_umap * add reembedding to config route parameters * front-end support for reembedding fetch and UI * remove unused imports * add loading state * save reembedding in reducer state * improve withColsFrom * transmit reembed schema to client; pick unique embedding names * display embeddings * format * lint * spaces, tab size 2 * lint * test hack for smoke-test race * back out hack sleep * add check for backed mode * add unit test for reembedding * lint * hide re-embedding CLI param from help
This commit is contained in:
@@ -321,7 +321,10 @@ describe("dataframe factories", () => {
|
|||||||
test("KeyIndex", () => {
|
test("KeyIndex", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[["red", "blue"], [true, false]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["colors", "bools"])
|
new Dataframe.KeyIndex(["colors", "bools"])
|
||||||
);
|
);
|
||||||
@@ -341,7 +344,10 @@ describe("dataframe factories", () => {
|
|||||||
test("DenseInt32Index", () => {
|
test("DenseInt32Index", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[["red", "blue"], [true, false]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.DenseInt32Index([74, 75])
|
new Dataframe.DenseInt32Index([74, 75])
|
||||||
);
|
);
|
||||||
@@ -363,7 +369,10 @@ describe("dataframe factories", () => {
|
|||||||
test("DenseInt32Index promote", () => {
|
test("DenseInt32Index promote", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[["red", "blue"], [true, false]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.DenseInt32Index([74, 75])
|
new Dataframe.DenseInt32Index([74, 75])
|
||||||
);
|
);
|
||||||
@@ -385,7 +394,10 @@ describe("dataframe factories", () => {
|
|||||||
test("IdentityInt32Index with last", () => {
|
test("IdentityInt32Index with last", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[["red", "blue"], [true, false]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -407,7 +419,10 @@ describe("dataframe factories", () => {
|
|||||||
test("IdentityInt32Index promote", () => {
|
test("IdentityInt32Index promote", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[["red", "blue"], [true, false]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -461,7 +476,11 @@ describe("dataframe factories", () => {
|
|||||||
*/
|
*/
|
||||||
const dfA = new Dataframe.Dataframe(
|
const dfA = new Dataframe.Dataframe(
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[["red", "blue"], [true, false], [1, 0]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||||
);
|
);
|
||||||
@@ -520,13 +539,87 @@ describe("dataframe factories", () => {
|
|||||||
expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
|
expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
|
||||||
expect(dfC.col("bools").asArray()).toEqual([true, false]);
|
expect(dfC.col("bools").asArray()).toEqual([true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("column picking", () => {
|
||||||
|
const dfEmpty = Dataframe.Dataframe.empty();
|
||||||
|
const dfA = new Dataframe.Dataframe(
|
||||||
|
[2, 1],
|
||||||
|
[["red", "blue"]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors"])
|
||||||
|
);
|
||||||
|
const dfB = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dfX = dfEmpty.withColsFrom(dfB, ["colors", "bools"]);
|
||||||
|
expect(dfX).toBeDefined();
|
||||||
|
expect(dfX.dims).toEqual([2, 2]);
|
||||||
|
expect(dfX.colIndex.keys()).toEqual(["colors", "bools"]);
|
||||||
|
expect(dfX.rowIndex).toEqual(dfB.rowIndex);
|
||||||
|
expect(dfX.icol(0).asArray()).toEqual(dfB.icol(0).asArray());
|
||||||
|
|
||||||
|
const dfY = dfA.withColsFrom(dfB, ["numbers"]);
|
||||||
|
expect(dfY).toBeDefined();
|
||||||
|
expect(dfY.dims).toEqual([2, 2]);
|
||||||
|
expect(dfY.colIndex.keys()).toEqual(["colors", "numbers"]);
|
||||||
|
expect(dfY.rowIndex).toEqual(dfA.rowIndex);
|
||||||
|
expect(dfY.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||||
|
|
||||||
|
const dfZ = dfA.withColsFrom(dfEmpty, []);
|
||||||
|
expect(dfZ).toBeDefined();
|
||||||
|
expect(dfZ.dims).toEqual(dfA.dims);
|
||||||
|
expect(dfZ.colIndex.keys()).toEqual(dfA.colIndex.keys());
|
||||||
|
expect(dfZ.rowIndex).toEqual(dfA.rowIndex);
|
||||||
|
expect(dfZ.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||||
|
|
||||||
|
expect(() => dfA.withColsFrom(dfB, ["bools", "colors"])).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("column aliasing", () => {
|
||||||
|
const dfA = new Dataframe.Dataframe(
|
||||||
|
[2, 1],
|
||||||
|
[["red", "blue"]],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors"])
|
||||||
|
);
|
||||||
|
const dfB = new Dataframe.Dataframe(
|
||||||
|
[2, 3],
|
||||||
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dfX = dfA.withColsFrom(dfB, { colors: "_colors", bools: "_bools" });
|
||||||
|
expect(dfX).toBeDefined();
|
||||||
|
expect(dfX.dims).toEqual([2, 3]);
|
||||||
|
expect(dfX.colIndex.keys()).toEqual(["colors", "_colors", "_bools"]);
|
||||||
|
expect(dfX.rowIndex).toEqual(dfA.rowIndex);
|
||||||
|
expect(dfX.icol(0).asArray()).toEqual(dfA.icol(0).asArray());
|
||||||
|
expect(dfX.col("_colors").asArray()).toBe(dfB.col("colors").asArray());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dropCol", () => {
|
describe("dropCol", () => {
|
||||||
test("KeyIndex", () => {
|
test("KeyIndex", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[["red", "blue"], [true, false], [1, 0]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
new Dataframe.KeyIndex(["colors", "bools", "numbers"])
|
||||||
);
|
);
|
||||||
@@ -545,7 +638,11 @@ describe("dataframe factories", () => {
|
|||||||
test("IdentityInt32Index drop first", () => {
|
test("IdentityInt32Index drop first", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[["red", "blue"], [true, false], [1, 0]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -565,7 +662,11 @@ describe("dataframe factories", () => {
|
|||||||
test("IdentityInt32Index drop last", () => {
|
test("IdentityInt32Index drop last", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[["red", "blue"], [true, false], [1, 0]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -585,7 +686,11 @@ describe("dataframe factories", () => {
|
|||||||
test("DenseInt32Index", () => {
|
test("DenseInt32Index", () => {
|
||||||
const df = new Dataframe.Dataframe(
|
const df = new Dataframe.Dataframe(
|
||||||
[2, 3],
|
[2, 3],
|
||||||
[["red", "blue"], [true, false], [1, 0]],
|
[
|
||||||
|
["red", "blue"],
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.DenseInt32Index([102, 101, 100])
|
new Dataframe.DenseInt32Index([102, 101, 100])
|
||||||
);
|
);
|
||||||
@@ -653,7 +758,10 @@ describe("dataframe factories", () => {
|
|||||||
test("renameCol", () => {
|
test("renameCol", () => {
|
||||||
const dfA = new Dataframe.Dataframe(
|
const dfA = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[[true, false], [1, 0]],
|
[
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["A", "B"])
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
);
|
);
|
||||||
@@ -671,7 +779,10 @@ describe("dataframe col", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
df = new Dataframe.Dataframe(
|
df = new Dataframe.Dataframe(
|
||||||
[2, 2],
|
[2, 2],
|
||||||
[[true, false], [1, 0]],
|
[
|
||||||
|
[true, false],
|
||||||
|
[1, 0]
|
||||||
|
],
|
||||||
null,
|
null,
|
||||||
new Dataframe.KeyIndex(["A", "B"])
|
new Dataframe.KeyIndex(["A", "B"])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
doBinaryRequest,
|
doBinaryRequest,
|
||||||
dispatchNetworkErrorMessageToUser
|
dispatchNetworkErrorMessageToUser
|
||||||
} from "../util/actionHelpers";
|
} from "../util/actionHelpers";
|
||||||
|
import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
return promise to fetch the OBS annotations we need to load. Omit anything
|
return promise to fetch the OBS annotations we need to load. Omit anything
|
||||||
@@ -387,6 +388,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
|||||||
|
|
||||||
const resetWorldToUniverse = () => (dispatch, getState) => {
|
const resetWorldToUniverse = () => (dispatch, getState) => {
|
||||||
const { universe } = getState();
|
const { universe } = getState();
|
||||||
|
reembedResetWorldToUniverse(dispatch, getState);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "reset World to eq Universe",
|
type: "reset World to eq Universe",
|
||||||
universe
|
universe
|
||||||
@@ -451,7 +453,8 @@ export default {
|
|||||||
requestDifferentialExpression,
|
requestDifferentialExpression,
|
||||||
requestSingleGeneExpressionCountsForColoringPOST,
|
requestSingleGeneExpressionCountsForColoringPOST,
|
||||||
requestUserDefinedGene,
|
requestUserDefinedGene,
|
||||||
|
requestReembed,
|
||||||
resetWorldToUniverse,
|
resetWorldToUniverse,
|
||||||
saveObsAnnotations,
|
saveObsAnnotations,
|
||||||
setWorldToSelection,
|
setWorldToSelection
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { API } from "../globals";
|
||||||
|
import { Universe } from "../util/stateManager";
|
||||||
|
import {
|
||||||
|
postNetworkErrorToast,
|
||||||
|
postAsyncSuccessToast,
|
||||||
|
postAsyncFailureToast
|
||||||
|
} from "../components/framework/toasters";
|
||||||
|
|
||||||
|
function abortableFetch(request, opts, timeout = 0) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
|
return {
|
||||||
|
abort: () => controller.abort(),
|
||||||
|
isAborted: () => signal.aborted,
|
||||||
|
ready: () => {
|
||||||
|
if (timeout) {
|
||||||
|
setTimeout(() => controller.abort(), timeout);
|
||||||
|
}
|
||||||
|
return fetch(request, { ...opts, signal });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReembedFetch(dispatch, getState) {
|
||||||
|
const state = getState();
|
||||||
|
let cells = state.world.obsAnnotations.rowIndex.keys();
|
||||||
|
|
||||||
|
// These lines ensure that we convert any TypedArray to an Array.
|
||||||
|
// This is necessary because JSON.stringify() does some very strange
|
||||||
|
// things with TypedArrays (they are marshalled to JSON objects, rather
|
||||||
|
// than being marshalled as a JSON array).
|
||||||
|
cells = Array.isArray(cells) ? cells : Array.from(cells);
|
||||||
|
|
||||||
|
const af = abortableFetch(
|
||||||
|
`${API.prefix}${API.version}layout/obs`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: new Headers({
|
||||||
|
Accept: "application/octet-stream",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
method: "umap",
|
||||||
|
filter: { obs: { index: cells } }
|
||||||
|
}),
|
||||||
|
credentials: "include"
|
||||||
|
},
|
||||||
|
60000 // 1 minute timeout
|
||||||
|
);
|
||||||
|
dispatch({
|
||||||
|
type: "reembed: request start",
|
||||||
|
abortableFetch: af
|
||||||
|
});
|
||||||
|
const res = await af.ready();
|
||||||
|
|
||||||
|
if (
|
||||||
|
res.ok &&
|
||||||
|
res.headers.get("Content-Type").includes("application/octet-stream")
|
||||||
|
) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// else an error
|
||||||
|
let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
|
||||||
|
const body = await res.text();
|
||||||
|
if (body && body.length > 0) {
|
||||||
|
msg = `${msg} -- ${body}`;
|
||||||
|
}
|
||||||
|
postNetworkErrorToast(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
functions below are dispatch-able
|
||||||
|
*/
|
||||||
|
export function requestReembed() {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
try {
|
||||||
|
const res = await doReembedFetch(dispatch, getState);
|
||||||
|
const schema = JSON.parse(res.headers.get("CxG-Schema"));
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
const df = Universe.matrixFBSToDataframe(buffer);
|
||||||
|
dispatch({
|
||||||
|
type: "reembed: request completed"
|
||||||
|
});
|
||||||
|
dispatch({
|
||||||
|
type: "reembed: add reembedding",
|
||||||
|
embedding: df,
|
||||||
|
schema
|
||||||
|
});
|
||||||
|
postAsyncSuccessToast("Re-embedding has completed.");
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({
|
||||||
|
type: "reembed: request aborted"
|
||||||
|
});
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
postAsyncFailureToast("Re-embedding calculation was aborted.");
|
||||||
|
} else {
|
||||||
|
postNetworkErrorToast(`Re-embedding: ${error.message}`);
|
||||||
|
}
|
||||||
|
console.log("Reembed exception:", error, error.name, error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reembedResetWorldToUniverse(dispatch, getState) {
|
||||||
|
const { reembedController } = getState();
|
||||||
|
if (reembedController.pendingFetch) reembedController.pendingFetch.abort();
|
||||||
|
dispatch({
|
||||||
|
type: "reembed: clear all reembeddings"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Position, Toaster, Intent } from "@blueprintjs/core";
|
|||||||
|
|
||||||
/** Singleton toaster instance. Create separate instances for different options. */
|
/** Singleton toaster instance. Create separate instances for different options. */
|
||||||
|
|
||||||
const ErrorToastTopCenter = Toaster.create({
|
const ToastTopCenter = Toaster.create({
|
||||||
className: "recipe-toaster",
|
className: "recipe-toaster",
|
||||||
position: Position.TOP
|
position: Position.TOP
|
||||||
});
|
});
|
||||||
@@ -11,21 +11,38 @@ const ErrorToastTopCenter = Toaster.create({
|
|||||||
A "user" error - eg, bad input
|
A "user" error - eg, bad input
|
||||||
*/
|
*/
|
||||||
export const postUserErrorToast = message =>
|
export const postUserErrorToast = message =>
|
||||||
ErrorToastTopCenter.show({ message, intent: Intent.WARNING });
|
ToastTopCenter.show({ message, intent: Intent.WARNING });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
A toast the user must dismiss manually, because they need to act on its information,
|
A toast the user must dismiss manually, because they need to act on its information,
|
||||||
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
|
ie., 8 bulk add genes out of 40 were bad. Manually see which ones and fix.
|
||||||
*/
|
*/
|
||||||
export const keepAroundErrorToast = message =>
|
export const keepAroundErrorToast = message =>
|
||||||
ErrorToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
|
ToastTopCenter.show({ message, timeout: 0, intent: Intent.WARNING });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
a hard network error
|
a hard network error
|
||||||
*/
|
*/
|
||||||
export const postNetworkErrorToast = message =>
|
export const postNetworkErrorToast = message =>
|
||||||
ErrorToastTopCenter.show({
|
ToastTopCenter.show({
|
||||||
message,
|
message,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
intent: Intent.DANGER
|
intent: Intent.DANGER
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Async message to user
|
||||||
|
*/
|
||||||
|
export const postAsyncSuccessToast = message =>
|
||||||
|
ToastTopCenter.show({
|
||||||
|
message,
|
||||||
|
timeout: 10000,
|
||||||
|
intent: Intent.SUCCESS
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postAsyncFailureToast = message =>
|
||||||
|
ToastTopCenter.show({
|
||||||
|
message,
|
||||||
|
timeout: 10000,
|
||||||
|
intent: Intent.WARNING
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AnchorButton,
|
||||||
|
ButtonGroup,
|
||||||
|
Popover,
|
||||||
|
Button,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Tooltip,
|
||||||
|
Position
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import * as globals from "../../globals";
|
||||||
|
import { World } from "../../util/stateManager";
|
||||||
|
import actions from "../../actions";
|
||||||
|
|
||||||
|
@connect(state => ({
|
||||||
|
universe: state.universe,
|
||||||
|
world: state.world,
|
||||||
|
layoutChoice: state.layoutChoice,
|
||||||
|
reembedController: state.reembedController,
|
||||||
|
enableReembedding: state.config?.parameters?.["enable-reembedding"] ?? false
|
||||||
|
}))
|
||||||
|
class Embedding extends React.PureComponent {
|
||||||
|
handleLayoutChoiceChange = e => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "set layout choice",
|
||||||
|
layoutChoice: e.currentTarget.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderReembedding() {
|
||||||
|
const {
|
||||||
|
enableReembedding,
|
||||||
|
world,
|
||||||
|
universe,
|
||||||
|
dispatch,
|
||||||
|
reembedController
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (!enableReembedding) return null;
|
||||||
|
|
||||||
|
const loading = !!reembedController?.pendingFetch;
|
||||||
|
const disabled = World.worldEqUniverse(world, universe);
|
||||||
|
const tipContent = disabled
|
||||||
|
? "Subset cells first, then click to recompute UMAP embedding."
|
||||||
|
: "Click to recompute UMAP embedding on the current cell subset.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={tipContent}
|
||||||
|
position="bottom"
|
||||||
|
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||||
|
>
|
||||||
|
<AnchorButton
|
||||||
|
icon="new-object"
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => dispatch(actions.requestReembed())}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { layoutChoice } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup
|
||||||
|
style={{
|
||||||
|
marginRight: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
target={
|
||||||
|
<Tooltip
|
||||||
|
content="Select embedding for visualization"
|
||||||
|
position="bottom"
|
||||||
|
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-testid="layout-choice"
|
||||||
|
icon="heatmap"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
position={Position.BOTTOM_RIGHT}
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: 10
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
label="Embedding Choice"
|
||||||
|
onChange={this.handleLayoutChoiceChange}
|
||||||
|
selectedValue={layoutChoice.current}
|
||||||
|
>
|
||||||
|
{layoutChoice.available.map(name => (
|
||||||
|
<Radio label={name} value={name} key={name} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{this.renderReembedding()}
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Embedding;
|
||||||
@@ -1,20 +1,12 @@
|
|||||||
// jshint esversion: 6
|
// jshint esversion: 6
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import {
|
import { Button, ButtonGroup, AnchorButton, Tooltip } from "@blueprintjs/core";
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
AnchorButton,
|
|
||||||
Tooltip,
|
|
||||||
Popover,
|
|
||||||
Position,
|
|
||||||
RadioGroup,
|
|
||||||
Radio,
|
|
||||||
} from "@blueprintjs/core";
|
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
import actions from "../../actions";
|
import actions from "../../actions";
|
||||||
import CellSetButton from "./cellSetButtons";
|
import CellSetButton from "./cellSetButtons";
|
||||||
import Clip from "./clip";
|
import Clip from "./clip";
|
||||||
|
import Embedding from "./embedding";
|
||||||
import InformationMenu from "./infoMenu";
|
import InformationMenu from "./infoMenu";
|
||||||
import Subset from "./subset";
|
import Subset from "./subset";
|
||||||
import UndoRedoReset from "./undoRedo";
|
import UndoRedoReset from "./undoRedo";
|
||||||
@@ -24,7 +16,6 @@ import UndoRedoReset from "./undoRedo";
|
|||||||
world: state.world,
|
world: state.world,
|
||||||
crossfilter: state.crossfilter,
|
crossfilter: state.crossfilter,
|
||||||
differential: state.differential,
|
differential: state.differential,
|
||||||
layoutChoice: state.layoutChoice,
|
|
||||||
graphInteractionMode: state.controls.graphInteractionMode,
|
graphInteractionMode: state.controls.graphInteractionMode,
|
||||||
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)),
|
||||||
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)),
|
||||||
@@ -171,14 +162,6 @@ class MenuBar extends React.Component {
|
|||||||
this.setState({ pendingClipPercentiles: null });
|
this.setState({ pendingClipPercentiles: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLayoutChoiceChange = e => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch({
|
|
||||||
type: "set layout choice",
|
|
||||||
layoutChoice: e.currentTarget.value
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
computeDiffExp = () => {
|
computeDiffExp = () => {
|
||||||
const { dispatch, differential } = this.props;
|
const { dispatch, differential } = this.props;
|
||||||
if (differential.celllist1 && differential.celllist2) {
|
if (differential.celllist1 && differential.celllist2) {
|
||||||
@@ -224,7 +207,6 @@ class MenuBar extends React.Component {
|
|||||||
return world.nObs !== universe.nObs;
|
return world.nObs !== universe.nObs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
renderDiffExp() {
|
renderDiffExp() {
|
||||||
/* diffexp-related buttons may be disabled */
|
/* diffexp-related buttons may be disabled */
|
||||||
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
|
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
|
||||||
@@ -295,7 +277,6 @@ class MenuBar extends React.Component {
|
|||||||
selectionTool,
|
selectionTool,
|
||||||
clipPercentileMin,
|
clipPercentileMin,
|
||||||
clipPercentileMax,
|
clipPercentileMax,
|
||||||
layoutChoice,
|
|
||||||
graphInteractionMode,
|
graphInteractionMode,
|
||||||
aboutLink,
|
aboutLink,
|
||||||
showCentroidLabels
|
showCentroidLabels
|
||||||
@@ -335,7 +316,7 @@ class MenuBar extends React.Component {
|
|||||||
dispatch({ type: "increment graph render counter" });
|
dispatch({ type: "increment graph render counter" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ButtonGroup style={{marginRight: "10px"}}>
|
<ButtonGroup style={{ marginRight: "10px" }}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={selectionTooltip}
|
content={selectionTooltip}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
@@ -394,52 +375,7 @@ class MenuBar extends React.Component {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ButtonGroup
|
<Embedding />
|
||||||
style={{
|
|
||||||
marginRight: 10
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover
|
|
||||||
target={
|
|
||||||
<Tooltip
|
|
||||||
content="Select embedding for visualization"
|
|
||||||
position="bottom"
|
|
||||||
hoverOpenDelay={globals.tooltipHoverOpenDelay}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
data-testid="layout-choice"
|
|
||||||
icon="heatmap"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
position={Position.BOTTOM_RIGHT}
|
|
||||||
content={
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
flexDirection: "column",
|
|
||||||
padding: 10
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RadioGroup
|
|
||||||
label="Embedding Choice"
|
|
||||||
onChange={this.handleLayoutChoiceChange}
|
|
||||||
selectedValue={layoutChoice.current}
|
|
||||||
>
|
|
||||||
{layoutChoice.available.map(name => (
|
|
||||||
<Radio label={name} value={name} key={name} />
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
<Clip
|
<Clip
|
||||||
pendingClipPercentiles={pendingClipPercentiles}
|
pendingClipPercentiles={pendingClipPercentiles}
|
||||||
clipPercentileMin={clipPercentileMin}
|
clipPercentileMin={clipPercentileMin}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {AnchorButton, ButtonGroup, Tooltip} from "@blueprintjs/core";
|
import { AnchorButton, ButtonGroup, Tooltip } from "@blueprintjs/core";
|
||||||
import * as globals from "../../globals";
|
import * as globals from "../../globals";
|
||||||
|
|
||||||
|
|
||||||
function Subset(props) {
|
function Subset(props) {
|
||||||
const {
|
const {
|
||||||
subsetPossible,
|
subsetPossible,
|
||||||
subsetResetPossible,
|
subsetResetPossible,
|
||||||
handleSubset,
|
handleSubset,
|
||||||
handleSubsetReset,
|
handleSubsetReset
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonGroup style={{marginRight: "10px"}}>
|
<ButtonGroup style={{ marginRight: "10px" }}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content="Subset to currently selected cells and associated metadata"
|
content="Subset to currently selected cells and associated metadata"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ const ColorsReducer = (
|
|||||||
prevSharedState
|
prevSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "universe exists, but loading is still in progress":
|
case "universe exists, but loading is still in progress": {
|
||||||
case "reset World to eq Universe": {
|
/* initialize everything with default colors, no mode, no color-by accessor */
|
||||||
const { world } = nextSharedState;
|
const { world } = nextSharedState;
|
||||||
const colorMode = null;
|
const colorMode = null;
|
||||||
const colorAccessor = null;
|
const colorAccessor = null;
|
||||||
const { rgb, scale } = ColorHelpers.createColors(world, colorMode);
|
const { rgb, scale } = ColorHelpers.createColors(world);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
colorAccessor,
|
colorAccessor,
|
||||||
@@ -27,6 +27,22 @@ const ColorsReducer = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "reset World to eq Universe": {
|
||||||
|
/* need to rebuild colors as world may have changed, but don't switch modes */
|
||||||
|
const { world } = nextSharedState;
|
||||||
|
const { colorMode, colorAccessor } = state;
|
||||||
|
const { rgb, scale } = ColorHelpers.createColors(
|
||||||
|
world,
|
||||||
|
colorMode,
|
||||||
|
colorAccessor
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
rgb,
|
||||||
|
scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case "set clip quantiles":
|
case "set clip quantiles":
|
||||||
case "set World to current selection": {
|
case "set World to current selection": {
|
||||||
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
const { world: prevWorld, controls: prevControls } = prevSharedState;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import autosave from "./autosave";
|
|||||||
import ontology from "./ontology";
|
import ontology from "./ontology";
|
||||||
import centroidLabels from "./centroidLabels";
|
import centroidLabels from "./centroidLabels";
|
||||||
import pointDialation from "./pointDilation";
|
import pointDialation from "./pointDilation";
|
||||||
|
import { reembedController, reembedding } from "./reembed";
|
||||||
|
|
||||||
import undoableConfig from "./undoableConfig";
|
import undoableConfig from "./undoableConfig";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const Reducer = undoable(
|
|||||||
["world", world],
|
["world", world],
|
||||||
["ontology", ontology],
|
["ontology", ontology],
|
||||||
["annotations", annotations],
|
["annotations", annotations],
|
||||||
|
["reembedding", reembedding],
|
||||||
["layoutChoice", layoutChoice],
|
["layoutChoice", layoutChoice],
|
||||||
["categoricalSelection", categoricalSelection],
|
["categoricalSelection", categoricalSelection],
|
||||||
["continuousSelection", continuousSelection],
|
["continuousSelection", continuousSelection],
|
||||||
@@ -42,6 +44,7 @@ const Reducer = undoable(
|
|||||||
["responsive", responsive],
|
["responsive", responsive],
|
||||||
["centroidLabels", centroidLabels],
|
["centroidLabels", centroidLabels],
|
||||||
["pointDilation", pointDialation],
|
["pointDilation", pointDialation],
|
||||||
|
["reembedController", reembedController],
|
||||||
["autosave", autosave],
|
["autosave", autosave],
|
||||||
["resetCache", resetCache]
|
["resetCache", resetCache]
|
||||||
]),
|
]),
|
||||||
@@ -57,7 +60,8 @@ const Reducer = undoable(
|
|||||||
"differential",
|
"differential",
|
||||||
"layoutChoice",
|
"layoutChoice",
|
||||||
"centroidLabels",
|
"centroidLabels",
|
||||||
"annotations"
|
"annotations",
|
||||||
|
"reembedding"
|
||||||
],
|
],
|
||||||
undoableConfig
|
undoableConfig
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ function bestDefaultLayout(layouts) {
|
|||||||
return layouts[0];
|
return layouts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setToDefaultLayout(world) {
|
||||||
|
const { schema } = world;
|
||||||
|
const available = schema.layout.obs.map(v => v.name).sort();
|
||||||
|
const current = bestDefaultLayout(available);
|
||||||
|
const currentDimNames = schema.layout.obsByName[current].dims;
|
||||||
|
return { available, current, currentDimNames };
|
||||||
|
}
|
||||||
|
|
||||||
const LayoutChoice = (
|
const LayoutChoice = (
|
||||||
state = {
|
state = {
|
||||||
available: [], // all available choices
|
available: [], // all available choices
|
||||||
@@ -24,14 +32,13 @@ const LayoutChoice = (
|
|||||||
nextSharedState
|
nextSharedState
|
||||||
) => {
|
) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "universe exists, but loading is still in progress":
|
case "universe exists, but loading is still in progress": {
|
||||||
case "reset World to eq Universe": {
|
|
||||||
// set default to default
|
// set default to default
|
||||||
const { schema } = nextSharedState.world;
|
const { universe } = nextSharedState;
|
||||||
const available = schema.layout.obs.map(v => v.name).sort();
|
return {
|
||||||
const current = bestDefaultLayout(available);
|
...state,
|
||||||
const currentDimNames = schema.layout.obsByName[current].dims;
|
...setToDefaultLayout(universe)
|
||||||
return { available, current, currentDimNames };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "set layout choice": {
|
case "set layout choice": {
|
||||||
@@ -41,6 +48,31 @@ const LayoutChoice = (
|
|||||||
return { ...state, current, currentDimNames };
|
return { ...state, current, currentDimNames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "reembed: add reembedding": {
|
||||||
|
const name = action.schema.name;
|
||||||
|
const available = Array.from(new Set(state.available).add(name));
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reembed: clear all reembeddings": {
|
||||||
|
const { universe } = nextSharedState;
|
||||||
|
const { current } = state;
|
||||||
|
const dflt = setToDefaultLayout(universe);
|
||||||
|
if (dflt.available.includes(current)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
available: dflt.available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...dflt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
controller state is not part of the undo/redo history
|
||||||
|
*/
|
||||||
|
export const reembedController = (
|
||||||
|
state = {
|
||||||
|
pendingFetch: null
|
||||||
|
},
|
||||||
|
action
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reembed: request start": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingFetch: action.abortableFetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "reembed: request aborted":
|
||||||
|
case "reembed: request cancel":
|
||||||
|
case "reembed: request completed": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pendingFetch: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
actual reembedding data is part of the undo/redo history
|
||||||
|
*/
|
||||||
|
export const reembedding = (
|
||||||
|
state = {
|
||||||
|
reembeddings: new Map()
|
||||||
|
},
|
||||||
|
action
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reembed: add reembedding": {
|
||||||
|
const { schema, embedding } = action;
|
||||||
|
const { name } = schema.name;
|
||||||
|
const { reembeddings } = state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
reembeddings: new Map(reembeddings).set(name, {
|
||||||
|
name,
|
||||||
|
schema,
|
||||||
|
embedding
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "reembed: clear all reembeddings": {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
reembeddings: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
ControlsHelpers,
|
ControlsHelpers,
|
||||||
AnnotationsHelpers
|
AnnotationsHelpers
|
||||||
} from "../util/stateManager";
|
} from "../util/stateManager";
|
||||||
|
import {
|
||||||
|
addObsLayout,
|
||||||
|
removeObsLayout
|
||||||
|
} from "../util/stateManager/schemaHelpers";
|
||||||
import clip from "../util/clip";
|
import clip from "../util/clip";
|
||||||
import quantile from "../util/quantile";
|
import quantile from "../util/quantile";
|
||||||
|
|
||||||
@@ -32,6 +36,7 @@ const WorldReducer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "universe: column load success": {
|
case "universe: column load success": {
|
||||||
|
/* incremental initial data load - always assumes world == universe */
|
||||||
const { universe } = nextSharedState;
|
const { universe } = nextSharedState;
|
||||||
const { dim } = action;
|
const { dim } = action;
|
||||||
return {
|
return {
|
||||||
@@ -276,6 +281,52 @@ const WorldReducer = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "reembed: add reembedding": {
|
||||||
|
// new embedding loaded, which *only* affects world's layout.
|
||||||
|
// It may be new, or it may replace a previous re-embedding.
|
||||||
|
const { obsLayout: origObsLayout, schema: origSchema } = state;
|
||||||
|
const { embedding, schema: embeddingSchema } = action;
|
||||||
|
|
||||||
|
const { dims, name } = embeddingSchema;
|
||||||
|
let obsLayout = origObsLayout;
|
||||||
|
let schema = origSchema;
|
||||||
|
|
||||||
|
// alias the names the server sent us, in case they were not the same as the schema
|
||||||
|
const embedingLabels = embedding.colIndex.keys();
|
||||||
|
const labels = {
|
||||||
|
[embedingLabels[0]]: dims[0],
|
||||||
|
[embedingLabels[1]]: dims[1]
|
||||||
|
};
|
||||||
|
obsLayout = obsLayout.withColsFrom(embedding, labels);
|
||||||
|
schema = addObsLayout(schema, embeddingSchema);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
obsLayout,
|
||||||
|
schema
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reembed: clear all reembeddings": {
|
||||||
|
// reembedding was cleared -- remove from layout
|
||||||
|
const { obsLayout: origObsLayout, schema: origSchema } = state;
|
||||||
|
const { reembedding } = prevSharedState;
|
||||||
|
|
||||||
|
let schema = origSchema;
|
||||||
|
let obsLayout = origObsLayout;
|
||||||
|
|
||||||
|
reembedding.reembeddings.forEach((emb, name) => {
|
||||||
|
const { dims } = emb.schema;
|
||||||
|
obsLayout = obsLayout.dropCol(dims[0]);
|
||||||
|
obsLayout = obsLayout.dropCol(dims[1]);
|
||||||
|
schema = removeObsLayout(schema, name);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
obsLayout,
|
||||||
|
schema
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,37 +348,90 @@ class Dataframe {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
withColsFrom(dataframe) {
|
withColsFrom(dataframe, labels) {
|
||||||
/*
|
/*
|
||||||
return a new dataframe containing all columns from both `this` and the
|
return a new dataframe containing all columns from both `this` and the
|
||||||
provided of dataframe.
|
provided dataframe argument.
|
||||||
|
|
||||||
The row index from `this` will be used. All dataframes must have identical
|
The row index from `this` will be used. All dataframes must have identical
|
||||||
dimensionality, and no overlapping columns labels.
|
dimensionality, and no overlapping columns labels.
|
||||||
|
|
||||||
Special case, if either dataframe is empty, the other is returned unchanged.
|
Special case, if either dataframe is empty, the other is returned unchanged.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
* dataframe: a dataframe to combine with `this`
|
||||||
|
* labels: columns to pull from `dataframe` and combine with `this`. If falsey,
|
||||||
|
all columns are used. If an array, must contain a list of labels. If an
|
||||||
|
Object or Map, the key is the columns to pull, which will be stored into the
|
||||||
|
new dataframe as the value.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
newDf = df.withColsFrom(otherDf); // combines all columns from both
|
||||||
|
newDf = df.withColsFrom(otherDf, ['a']); // combines df with otherDf['a']
|
||||||
|
newDf = df.withColsFrom(otherDf, {a: 'b'}); // combines df with otherDf['a'], but calls it 'b'
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// resolve the source and dest label names.
|
||||||
|
let srcLabels;
|
||||||
|
let dstLabels;
|
||||||
|
if (!labels) {
|
||||||
|
// combine all columns
|
||||||
|
dstLabels = dataframe.colIndex.keys();
|
||||||
|
srcLabels = dstLabels;
|
||||||
|
} else if (Array.isArray(labels)) {
|
||||||
|
// combine subset of keys with no aliasing
|
||||||
|
dstLabels = labels;
|
||||||
|
srcLabels = labels;
|
||||||
|
} else if (labels instanceof Map) {
|
||||||
|
// aliasing with a Map
|
||||||
|
srcLabels = Array.from(labels.keys());
|
||||||
|
dstLabels = Array.from(labels.values());
|
||||||
|
} else {
|
||||||
|
// aliasing with an Object
|
||||||
|
srcLabels = Object.keys(labels);
|
||||||
|
dstLabels = Object.values(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if datafame is empty, and no specific labels specified, noop.
|
||||||
|
if (dataframe.isEmpty()) {
|
||||||
|
if (!labels || srcLabels.length === 0) return this;
|
||||||
|
throw new Error("Empty dataframe, unable to pick columns");
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isEmpty()) {
|
if (this.isEmpty()) {
|
||||||
|
// 1. subset dataframe from source keys
|
||||||
|
// 2. alias names
|
||||||
|
dataframe = dataframe.subset(null, srcLabels);
|
||||||
|
for (let i = 0; i < srcLabels.length; i += 1) {
|
||||||
|
dataframe = dataframe.renameCol(srcLabels[i], dstLabels[i]);
|
||||||
|
}
|
||||||
return dataframe;
|
return dataframe;
|
||||||
}
|
}
|
||||||
if (dataframe.isEmpty()) {
|
|
||||||
return this;
|
// otherwise, bulid a new dataframe combining columns from both
|
||||||
|
|
||||||
|
const srcOffsets = srcLabels.map(l => dataframe.colIndex.getOffset(l));
|
||||||
|
|
||||||
|
// check for label collisions
|
||||||
|
if (dstLabels.some(this.hasCol, this)) {
|
||||||
|
throw new Error("duplicate key collision");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.colIndex.keys().forEach(key => {
|
// const dims = [this.dims[0], this.dims[1] + dataframe.dims[1]];
|
||||||
if (dataframe.has(key)) {
|
const dims = [this.dims[0], this.dims[1] + srcOffsets.length];
|
||||||
throw new Error("duplicate key collision");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dims = [this.dims[0], this.dims[1] + dataframe.dims[1]];
|
|
||||||
const { rowIndex } = this;
|
const { rowIndex } = this;
|
||||||
const columns = [...this.__columns, ...dataframe.__columns];
|
const columns = [
|
||||||
const colIndex = this.colIndex.withLabels(dataframe.colIndex.keys());
|
...this.__columns,
|
||||||
|
...srcOffsets.map(i => dataframe.__columns[i])
|
||||||
|
];
|
||||||
|
const colIndex = this.colIndex.withLabels(dstLabels);
|
||||||
const columnsAccessor = [
|
const columnsAccessor = [
|
||||||
...this.__columnsAccessor,
|
...this.__columnsAccessor,
|
||||||
...dataframe.__columnsAccessor
|
...srcOffsets.map(i => dataframe.__columnsAccessor[i])
|
||||||
];
|
];
|
||||||
|
|
||||||
return new this.constructor(
|
return new this.constructor(
|
||||||
dims,
|
dims,
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
Helpers for schema management
|
Helpers for schema management
|
||||||
|
|
||||||
|
TODO: all this would be much more natural if done with a framework
|
||||||
|
like immutable.js
|
||||||
*/
|
*/
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
|
||||||
@@ -31,8 +34,8 @@ export function indexEntireSchema(schema) {
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _copy(schema) {
|
function _copyObsAnno(schema) {
|
||||||
/* redux copy conventions - WARNING, only for modifyign obs annotations */
|
/* redux copy conventions - WARNING, only for modifying obs annotations */
|
||||||
return {
|
return {
|
||||||
...schema,
|
...schema,
|
||||||
annotations: {
|
annotations: {
|
||||||
@@ -42,7 +45,17 @@ function _copy(schema) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function _reindex(schema) {
|
function _copyObsLayout(schema) {
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
layout: {
|
||||||
|
...schema.layout,
|
||||||
|
obs: _.cloneDeep(schema.layout.obs)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _reindexObsAnno(schema) {
|
||||||
/* reindex obs annotations ONLY */
|
/* reindex obs annotations ONLY */
|
||||||
schema.annotations.obsByName = fromEntries(
|
schema.annotations.obsByName = fromEntries(
|
||||||
schema.annotations.obs.columns.map(v => [v.name, v])
|
schema.annotations.obs.columns.map(v => [v.name, v])
|
||||||
@@ -50,18 +63,25 @@ function _reindex(schema) {
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _reindexObsLayout(schema) {
|
||||||
|
schema.layout.obsByName = fromEntries(
|
||||||
|
schema.layout.obs.map(v => [v.name, v])
|
||||||
|
);
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
export function removeObsAnnoColumn(schema, name) {
|
export function removeObsAnnoColumn(schema, name) {
|
||||||
const newSchema = _copy(schema);
|
const newSchema = _copyObsAnno(schema);
|
||||||
newSchema.annotations.obs.columns = schema.annotations.obs.columns.filter(
|
newSchema.annotations.obs.columns = schema.annotations.obs.columns.filter(
|
||||||
v => v.name !== name
|
v => v.name !== name
|
||||||
);
|
);
|
||||||
return _reindex(newSchema);
|
return _reindexObsAnno(newSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addObsAnnoColumn(schema, name, defn) {
|
export function addObsAnnoColumn(schema, name, defn) {
|
||||||
const newSchema = _copy(schema);
|
const newSchema = _copyObsAnno(schema);
|
||||||
newSchema.annotations.obs.columns.push(defn);
|
newSchema.annotations.obs.columns.push(defn);
|
||||||
return _reindex(newSchema);
|
return _reindexObsAnno(newSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeObsAnnoCategory(schema, name, category) {
|
export function removeObsAnnoCategory(schema, name, category) {
|
||||||
@@ -73,7 +93,7 @@ export function removeObsAnnoCategory(schema, name, category) {
|
|||||||
const idx = categories.indexOf(category);
|
const idx = categories.indexOf(category);
|
||||||
if (idx === -1) throw new Error("category does not exist");
|
if (idx === -1) throw new Error("category does not exist");
|
||||||
|
|
||||||
const newSchema = _reindex(_copy(schema));
|
const newSchema = _reindexObsAnno(_copyObsAnno(schema));
|
||||||
|
|
||||||
/* remove category. Do not need to resort as this can't change presentation order */
|
/* remove category. Do not need to resort as this can't change presentation order */
|
||||||
newSchema.annotations.obsByName[name].categories.splice(idx, 1);
|
newSchema.annotations.obsByName[name].categories.splice(idx, 1);
|
||||||
@@ -89,7 +109,7 @@ export function addObsAnnoCategory(schema, name, category) {
|
|||||||
const idx = categories.indexOf(category);
|
const idx = categories.indexOf(category);
|
||||||
if (idx !== -1) throw new Error("category already exists");
|
if (idx !== -1) throw new Error("category already exists");
|
||||||
|
|
||||||
const newSchema = _reindex(_copy(schema));
|
const newSchema = _reindexObsAnno(_copyObsAnno(schema));
|
||||||
|
|
||||||
/* add category, retaining presentation sort order */
|
/* add category, retaining presentation sort order */
|
||||||
const catAnno = newSchema.annotations.obsByName[name];
|
const catAnno = newSchema.annotations.obsByName[name];
|
||||||
@@ -99,3 +119,17 @@ export function addObsAnnoCategory(schema, name, category) {
|
|||||||
]);
|
]);
|
||||||
return newSchema;
|
return newSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addObsLayout(schema, layout) {
|
||||||
|
/* add or replace a layout */
|
||||||
|
const newSchema = _copyObsLayout(schema);
|
||||||
|
newSchema.layout.obs.push(layout);
|
||||||
|
return _reindexObsLayout(newSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeObsLayout(schema, name) {
|
||||||
|
/* remove a layout */
|
||||||
|
const newSchema = _copyObsLayout(schema);
|
||||||
|
newSchema.layout.obs = schema.layout.obs.filter(v => v.name !== name);
|
||||||
|
return _reindexObsLayout(newSchema);
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ class LayoutObsAPI(Resource):
|
|||||||
def get(self, data_adaptor):
|
def get(self, data_adaptor):
|
||||||
return common_rest.layout_obs_get(request, data_adaptor)
|
return common_rest.layout_obs_get(request, data_adaptor)
|
||||||
|
|
||||||
|
@rest_get_data_adaptor
|
||||||
|
def put(self, data_adaptor):
|
||||||
|
return common_rest.layout_obs_put(request, data_adaptor)
|
||||||
|
|
||||||
|
|
||||||
def get_api_resources(bp_api):
|
def get_api_resources(bp_api):
|
||||||
api = Api(bp_api)
|
api = Api(bp_api)
|
||||||
|
|||||||
+19
-1
@@ -13,7 +13,7 @@ import click
|
|||||||
from server.common.utils import custom_format_warning
|
from server.common.utils import custom_format_warning
|
||||||
from server.common.utils import find_available_port, is_port_available, sort_options
|
from server.common.utils import find_available_port, is_port_available, sort_options
|
||||||
from server.common.errors import DatasetAccessError
|
from server.common.errors import DatasetAccessError
|
||||||
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataCacheManager
|
from server.data_common.matrix_loader import MatrixDataLoader, MatrixDataCacheManager, MatrixDataType
|
||||||
from server.common.annotations import AnnotationsLocalFile
|
from server.common.annotations import AnnotationsLocalFile
|
||||||
from server.common.app_config import AppConfig
|
from server.common.app_config import AppConfig
|
||||||
|
|
||||||
@@ -103,6 +103,14 @@ def config_args(func):
|
|||||||
metavar="<text>",
|
metavar="<text>",
|
||||||
help="Embedding name, eg, 'umap'. Repeat option for multiple embeddings. Defaults to all.",
|
help="Embedding name, eg, 'umap'. Repeat option for multiple embeddings. Defaults to all.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--experimental-enable-reembedding",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
show_default=False,
|
||||||
|
hidden=True,
|
||||||
|
help="Enable experimental on-demand re-embedding using UMAP. WARNING: may be very slow.",
|
||||||
|
)
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
@@ -278,6 +286,7 @@ def launch(
|
|||||||
disable_diffexp,
|
disable_diffexp,
|
||||||
experimental_annotations_ontology,
|
experimental_annotations_ontology,
|
||||||
experimental_annotations_ontology_obo,
|
experimental_annotations_ontology_obo,
|
||||||
|
experimental_enable_reembedding,
|
||||||
):
|
):
|
||||||
"""Launch the cellxgene data viewer.
|
"""Launch the cellxgene data viewer.
|
||||||
This web app lets you explore single-cell expression data.
|
This web app lets you explore single-cell expression data.
|
||||||
@@ -317,6 +326,14 @@ def launch(
|
|||||||
except DatasetAccessError as e:
|
except DatasetAccessError as e:
|
||||||
raise click.ClickException(str(e))
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
if experimental_enable_reembedding:
|
||||||
|
if matrix_data_loader.matrix_data_type() != MatrixDataType.H5AD:
|
||||||
|
raise click.ClickException("--experimental-enable-reembedding is only supported with H5AD files.")
|
||||||
|
if backed:
|
||||||
|
raise click.ClickException(
|
||||||
|
"--experimental-enable-reembedding is not supported when run in --backed mode."
|
||||||
|
)
|
||||||
|
|
||||||
file_size = matrix_data_loader.file_size()
|
file_size = matrix_data_loader.file_size()
|
||||||
if file_size > BIG_FILE_SIZE_THRESHOLD:
|
if file_size > BIG_FILE_SIZE_THRESHOLD:
|
||||||
click.echo(f"[cellxgene] Loading data from {basename(datapath)}, this may take a while...")
|
click.echo(f"[cellxgene] Loading data from {basename(datapath)}, this may take a while...")
|
||||||
@@ -402,6 +419,7 @@ def launch(
|
|||||||
var_names=var_names,
|
var_names=var_names,
|
||||||
anndata_backed=backed,
|
anndata_backed=backed,
|
||||||
disable_diffexp=disable_diffexp,
|
disable_diffexp=disable_diffexp,
|
||||||
|
enable_reembedding=experimental_enable_reembedding,
|
||||||
)
|
)
|
||||||
|
|
||||||
matrix_data_cache_manager = MatrixDataCacheManager()
|
matrix_data_cache_manager = MatrixDataCacheManager()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class AppConfig(object):
|
|||||||
self.max_category_items = 100
|
self.max_category_items = 100
|
||||||
self.diffexp_lfc_cutoff = 0.01
|
self.diffexp_lfc_cutoff = 0.01
|
||||||
self.disable_diffexp = False
|
self.disable_diffexp = False
|
||||||
|
self.enable_reembedding = False
|
||||||
self.anndata_backed = False
|
self.anndata_backed = False
|
||||||
|
|
||||||
# TODO these options may not apply to all datasets in the multi dataset.
|
# TODO these options may not apply to all datasets in the multi dataset.
|
||||||
@@ -56,6 +57,7 @@ class AppConfig(object):
|
|||||||
"var_names",
|
"var_names",
|
||||||
"anndata_backed",
|
"anndata_backed",
|
||||||
"disable_diffexp",
|
"disable_diffexp",
|
||||||
|
"enable_reembedding",
|
||||||
]
|
]
|
||||||
|
|
||||||
self.update(inputs, kw)
|
self.update(inputs, kw)
|
||||||
@@ -80,7 +82,7 @@ class AppConfig(object):
|
|||||||
# we have camalCase, hyphen-text, and underscore_text
|
# we have camalCase, hyphen-text, and underscore_text
|
||||||
|
|
||||||
# features
|
# features
|
||||||
features = [f.todict() for f in data_adaptor.get_features().values()]
|
features = [f.todict() for f in data_adaptor.get_features(annotation)]
|
||||||
|
|
||||||
# display_names
|
# display_names
|
||||||
title = self.get_title(data_adaptor)
|
title = self.get_title(data_adaptor)
|
||||||
@@ -105,6 +107,7 @@ class AppConfig(object):
|
|||||||
"diffexp_lfc_cutoff": self.diffexp_lfc_cutoff,
|
"diffexp_lfc_cutoff": self.diffexp_lfc_cutoff,
|
||||||
"backed": self.anndata_backed,
|
"backed": self.anndata_backed,
|
||||||
"disable-diffexp": self.disable_diffexp,
|
"disable-diffexp": self.disable_diffexp,
|
||||||
|
"enable-reembedding": self.enable_reembedding,
|
||||||
"annotations": False,
|
"annotations": False,
|
||||||
"annotations_file": None,
|
"annotations_file": None,
|
||||||
"annotations_output_dir": None,
|
"annotations_output_dir": None,
|
||||||
|
|||||||
+31
-1
@@ -165,7 +165,7 @@ def diffexp_obs_post(request, data_adaptor):
|
|||||||
try:
|
try:
|
||||||
diffexp = data_adaptor.diffexp_topN(set1_filter, set2_filter, count)
|
diffexp = data_adaptor.diffexp_topN(set1_filter, set2_filter, count)
|
||||||
return make_response(diffexp, HTTPStatus.OK, {"Content-Type": "application/json"})
|
return make_response(diffexp, HTTPStatus.OK, {"Content-Type": "application/json"})
|
||||||
except (ValueError, FilterError) as e:
|
except (ValueError, DisabledFeatureError, FilterError) as e:
|
||||||
return make_response(str(e), HTTPStatus.BAD_REQUEST)
|
return make_response(str(e), HTTPStatus.BAD_REQUEST)
|
||||||
except JSONEncodingValueError as e:
|
except JSONEncodingValueError as e:
|
||||||
# JSON encoding failure, usually due to bad data
|
# JSON encoding failure, usually due to bad data
|
||||||
@@ -188,3 +188,33 @@ def layout_obs_get(request, data_adaptor):
|
|||||||
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
|
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
|
return make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
def layout_obs_put(request, data_adaptor):
|
||||||
|
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
|
||||||
|
if preferred_mimetype != "application/octet-stream":
|
||||||
|
return make_response(f"Unsupported MIME type '{request.accept_mimetypes}'", HTTPStatus.NOT_ACCEPTABLE)
|
||||||
|
if not data_adaptor.config.enable_reembedding:
|
||||||
|
return make_response(f"Computed embedding not supported.", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
args = request.get_json()
|
||||||
|
filter = args["filter"] if args else None
|
||||||
|
if not filter:
|
||||||
|
return make_response("Error: obs filter is required", HTTPStatus.BAD_REQUEST)
|
||||||
|
method = args["method"] if args else "umap"
|
||||||
|
|
||||||
|
try:
|
||||||
|
schema, fbs = data_adaptor.compute_embedding(method, filter)
|
||||||
|
return make_response(
|
||||||
|
fbs,
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"CxG-Schema": json.dumps(schema),
|
||||||
|
"Access-Control-Expose-Headers": "CxG-Schema",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
return make_response(str(e), HTTPStatus.NOT_IMPLEMENTED)
|
||||||
|
except (ValueError, DisabledFeatureError, FilterError) as e:
|
||||||
|
return make_response(str(e), HTTPStatus.BAD_REQUEST)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
"""
|
||||||
|
Wrapper for various scanpy modules. Will raise NotImplementedError if the scanpy
|
||||||
|
module is not installed/available
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_scanpy_module():
|
||||||
|
try:
|
||||||
|
sc = importlib.import_module("scanpy")
|
||||||
|
# Future: we could enforce versions here, eg, lookat sc.__version__
|
||||||
|
return sc
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise NotImplementedError("Please install scanpy to enable UMAP re-embedding")
|
||||||
|
except Exception as e:
|
||||||
|
# will capture other ImportError corner cases
|
||||||
|
raise NotImplementedError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def scanpy_umap(adata, obs_mask=None, pca_options={}, neighbors_options={}, umap_options={}):
|
||||||
|
"""
|
||||||
|
Given adata and an obs mask, return a new embedding for adata[obs_mask, :]
|
||||||
|
as an ndarray of shape (len(obs_mask), N), where N>=2.
|
||||||
|
|
||||||
|
Do NOT mutate adata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# backed mode is incompatible with the current implementation
|
||||||
|
if adata.isbacked:
|
||||||
|
raise NotImplementedError("Backed mode is incompatible with re-embedding")
|
||||||
|
|
||||||
|
# safely get scanpy module, which may not be present.
|
||||||
|
sc = get_scanpy_module()
|
||||||
|
|
||||||
|
# https://github.com/theislab/anndata/issues/311
|
||||||
|
obs_mask = slice(None) if obs_mask is None else obs_mask
|
||||||
|
adata = adata[obs_mask, :].copy()
|
||||||
|
|
||||||
|
for k in list(adata.obsm.keys()):
|
||||||
|
del adata.obsm[k]
|
||||||
|
for k in list(adata.uns.keys()):
|
||||||
|
del adata.uns[k]
|
||||||
|
|
||||||
|
sc.pp.pca(adata, zero_center=None, n_comps=min(adata.n_obs - 1, 50), **pca_options)
|
||||||
|
sc.pp.neighbors(adata, **neighbors_options)
|
||||||
|
sc.tl.umap(adata, **umap_options)
|
||||||
|
|
||||||
|
return adata.obsm["X_umap"]
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
from pandas.core.dtypes.dtypes import CategoricalDtype
|
from pandas.core.dtypes.dtypes import CategoricalDtype
|
||||||
import anndata
|
import anndata
|
||||||
from scipy import sparse
|
from scipy import sparse
|
||||||
from packaging import version
|
from packaging import version
|
||||||
|
from datetime import datetime
|
||||||
|
from server_timing import Timing as ServerTiming
|
||||||
|
|
||||||
from server.data_common.data_adaptor import DataAdaptor
|
from server.data_common.data_adaptor import DataAdaptor
|
||||||
from server.data_common.fbs.matrix import encode_matrix_fbs
|
from server.data_common.fbs.matrix import encode_matrix_fbs
|
||||||
from server.common.utils import series_to_schema
|
from server.common.utils import series_to_schema
|
||||||
from server.common.constants import Axis, MAX_LAYOUTS
|
from server.common.constants import Axis, MAX_LAYOUTS
|
||||||
from server.common.errors import PrepareError, DatasetAccessError
|
from server.common.errors import PrepareError, DatasetAccessError, FilterError
|
||||||
from server.common.data_locator import DataLocator
|
from server.common.data_locator import DataLocator
|
||||||
|
from server.compute.scanpy import scanpy_umap
|
||||||
|
|
||||||
anndata_version = version.parse(str(anndata.__version__)).release
|
anndata_version = version.parse(str(anndata.__version__)).release
|
||||||
|
|
||||||
@@ -261,7 +265,10 @@ class AnndataAdaptor(DataAdaptor):
|
|||||||
return encode_matrix_fbs(df, col_idx=df.columns)
|
return encode_matrix_fbs(df, col_idx=df.columns)
|
||||||
|
|
||||||
def get_embedding_names(self):
|
def get_embedding_names(self):
|
||||||
""" function:
|
"""
|
||||||
|
Return pre-computed embeddings.
|
||||||
|
|
||||||
|
function:
|
||||||
a) generate list of default layouts
|
a) generate list of default layouts
|
||||||
b) validate layouts are legal. remove/warn on any that are not
|
b) validate layouts are legal. remove/warn on any that are not
|
||||||
c) cap total list of layouts at global const MAX_LAYOUTS
|
c) cap total list of layouts at global const MAX_LAYOUTS
|
||||||
@@ -294,6 +301,30 @@ class AnndataAdaptor(DataAdaptor):
|
|||||||
full_embedding = self.data.obsm[f"X_{ename}"]
|
full_embedding = self.data.obsm[f"X_{ename}"]
|
||||||
return full_embedding[:, 0:dims]
|
return full_embedding[:, 0:dims]
|
||||||
|
|
||||||
|
def compute_embedding(self, method, obsFilter):
|
||||||
|
if Axis.VAR in obsFilter:
|
||||||
|
raise FilterError("Observation filters may not contain variable conditions")
|
||||||
|
if method != "umap":
|
||||||
|
raise NotImplementedError(f"re-embedding method {method} is not available.")
|
||||||
|
try:
|
||||||
|
shape = self.get_shape()
|
||||||
|
obs_mask = self._axis_filter_to_mask(Axis.OBS, obsFilter["obs"], shape[0])
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise FilterError(f"Error parsing filter: {e}") from e
|
||||||
|
|
||||||
|
with ServerTiming.time("layout.compute"):
|
||||||
|
X_umap = scanpy_umap(self.data, obs_mask)
|
||||||
|
normalized_layout = DataAdaptor.normalize_embedding(X_umap)
|
||||||
|
|
||||||
|
# Server picks reemedding name, which must not collide with any other
|
||||||
|
# embedding name generated by this backed.
|
||||||
|
name = f"reembed:{method}_{datetime.now().isoformat(timespec='milliseconds')}"
|
||||||
|
dims = [f"{name}_0", f"{name}_1"]
|
||||||
|
df = pd.DataFrame(normalized_layout, columns=dims)
|
||||||
|
fbs = encode_matrix_fbs(df, col_idx=df.columns, row_idx=None)
|
||||||
|
schema = {"name": name, "type": "float32", "dims": dims}
|
||||||
|
return (schema, fbs)
|
||||||
|
|
||||||
def get_X_array(self, obs_mask=None, var_mask=None):
|
def get_X_array(self, obs_mask=None, var_mask=None):
|
||||||
if obs_mask is None:
|
if obs_mask is None:
|
||||||
obs_mask = slice(None)
|
obs_mask = slice(None)
|
||||||
|
|||||||
@@ -57,12 +57,18 @@ class DataAdaptor(metaclass=ABCMeta):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_embedding_names(self):
|
def get_embedding_names(self):
|
||||||
"""return a list of embedding names"""
|
"""return a list of pre-computed embedding names"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_embedding_array(self, ename, dims=2):
|
def get_embedding_array(self, ename, dims=2):
|
||||||
"""return an numpy array for the given embedding name."""
|
"""return an numpy array for the given pre-computed embedding name."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compute_embedding(self, method, filter):
|
||||||
|
"""compute a new embedding on the specified obs subset, and return a
|
||||||
|
tuple of (schema, fbs)."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -126,21 +132,15 @@ class DataAdaptor(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_features(self):
|
def get_features(self, annotations=None):
|
||||||
features = {}
|
"""Return list of features, to return as part of the config route"""
|
||||||
features["cluster"] = AppFeature("/cluster/")
|
features = [
|
||||||
|
AppFeature("/cluster/", method="POST", available=False),
|
||||||
if self.get_embedding_names():
|
AppFeature("/layout/obs", method="GET", available=self.get_embedding_names() is not None),
|
||||||
# TODO handle "var" when gene layout becomes available
|
AppFeature("/layout/obs", method="PUT", available=self.config.enable_reembedding),
|
||||||
features["layout_obs"] = AppFeature("/layout/obs", available=True)
|
AppFeature("/diffexp/", method="POST", available=not self.config.disable_diffexp),
|
||||||
else:
|
AppFeature("/annotations/obs", method="PUT", available=annotations is not None),
|
||||||
features["layout_obs"] = AppFeature("/layout/obs")
|
]
|
||||||
|
|
||||||
if self.config.disable_diffexp:
|
|
||||||
features["diffexp"] = AppFeature("/diffexp/")
|
|
||||||
else:
|
|
||||||
features["diffexp"] = AppFeature("/diffexp/", available=True)
|
|
||||||
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
def update_parameters(self, parameters):
|
def update_parameters(self, parameters):
|
||||||
@@ -294,6 +294,25 @@ class DataAdaptor(metaclass=ABCMeta):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise JSONEncodingValueError("Error encoding differential expression to JSON")
|
raise JSONEncodingValueError("Error encoding differential expression to JSON")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_embedding(embedding):
|
||||||
|
"""Normalize embedding layout to meet client assumptions.
|
||||||
|
Embedding is an ndarray, shape (n_obs, n)., where n is normally 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
# scale isotropically
|
||||||
|
min = embedding.min(axis=0)
|
||||||
|
max = embedding.max(axis=0)
|
||||||
|
scale = np.amax(max - min)
|
||||||
|
normalized_layout = (embedding - min) / scale
|
||||||
|
|
||||||
|
# translate to center on both axis
|
||||||
|
translate = 0.5 - ((max - min) / scale / 2)
|
||||||
|
normalized_layout = normalized_layout + translate
|
||||||
|
|
||||||
|
normalized_layout = normalized_layout.astype(dtype=np.float32)
|
||||||
|
return normalized_layout
|
||||||
|
|
||||||
def layout_to_fbs_matrix(self):
|
def layout_to_fbs_matrix(self):
|
||||||
""" same as layout, except returns a flatbuffer """
|
""" same as layout, except returns a flatbuffer """
|
||||||
"""
|
"""
|
||||||
@@ -312,18 +331,7 @@ class DataAdaptor(metaclass=ABCMeta):
|
|||||||
with ServerTiming.time(f"layout.query"):
|
with ServerTiming.time(f"layout.query"):
|
||||||
for ename in embeddings:
|
for ename in embeddings:
|
||||||
embedding = self.get_embedding_array(ename, 2)
|
embedding = self.get_embedding_array(ename, 2)
|
||||||
|
normalized_layout = DataAdaptor.normalize_embedding(embedding)
|
||||||
# scale isotropically
|
|
||||||
min = embedding.min(axis=0)
|
|
||||||
max = embedding.max(axis=0)
|
|
||||||
scale = np.amax(max - min)
|
|
||||||
normalized_layout = (embedding - min) / scale
|
|
||||||
|
|
||||||
# translate to center on both axis
|
|
||||||
translate = 0.5 - ((max - min) / scale / 2)
|
|
||||||
normalized_layout = normalized_layout + translate
|
|
||||||
|
|
||||||
normalized_layout = normalized_layout.astype(dtype=np.float32)
|
|
||||||
layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"]))
|
layout_data.append(pd.DataFrame(normalized_layout, columns=[f"{ename}_0", f"{ename}_1"]))
|
||||||
|
|
||||||
with ServerTiming.time(f"layout.encode"):
|
with ServerTiming.time(f"layout.encode"):
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ class CxgAdaptor(DataAdaptor):
|
|||||||
array = self.open_array(f"emb/{ename}")
|
array = self.open_array(f"emb/{ename}")
|
||||||
return array[:, 0:dims]
|
return array[:, 0:dims]
|
||||||
|
|
||||||
|
def compute_embedding(self, method, filter):
|
||||||
|
raise NotImplementedError("CXG does not yet support re-embedding")
|
||||||
|
|
||||||
def get_X_array(self, obs_mask=None, var_mask=None):
|
def get_X_array(self, obs_mask=None, var_mask=None):
|
||||||
obs_items = self._convert_mask(obs_mask)
|
obs_items = self._convert_mask(obs_mask)
|
||||||
var_items = self._convert_mask(var_mask)
|
var_items = self._convert_mask(var_mask)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from os import path
|
|||||||
import pytest
|
import pytest
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
import sys
|
||||||
import server.test.decode_fbs as decode_fbs
|
import server.test.decode_fbs as decode_fbs
|
||||||
from parameterized import parameterized_class
|
from parameterized import parameterized_class
|
||||||
|
|
||||||
@@ -97,7 +98,21 @@ class AdaptorTest(unittest.TestCase):
|
|||||||
self.data._create_schema()
|
self.data._create_schema()
|
||||||
|
|
||||||
def test_config(self):
|
def test_config(self):
|
||||||
self.assertEqual(self.data.get_features()["layout_obs"].available, True)
|
features = self.data.get_features(annotations=None)
|
||||||
|
|
||||||
|
# test each for singular presence and accuracy of available flag
|
||||||
|
def check_feature(method, path, available):
|
||||||
|
feature = list(
|
||||||
|
filter(lambda f: f.method == method and f.path == path and f.available == available, features)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(feature)
|
||||||
|
self.assertEqual(len(feature), 1)
|
||||||
|
|
||||||
|
check_feature("POST", "/cluster/", False)
|
||||||
|
check_feature("POST", "/diffexp/", not self.data.config.disable_diffexp)
|
||||||
|
check_feature("GET", "/layout/obs", True)
|
||||||
|
check_feature("PUT", "/layout/obs", self.data.config.enable_reembedding)
|
||||||
|
check_feature("PUT", "/annotations/obs", False)
|
||||||
|
|
||||||
def test_layout(self):
|
def test_layout(self):
|
||||||
fbs = self.data.layout_to_fbs_matrix()
|
fbs = self.data.layout_to_fbs_matrix()
|
||||||
@@ -185,3 +200,39 @@ class AdaptorTest(unittest.TestCase):
|
|||||||
self.assertEqual(data["n_rows"], 2638)
|
self.assertEqual(data["n_rows"], 2638)
|
||||||
self.assertEqual(data["n_cols"], 3)
|
self.assertEqual(data["n_cols"], 3)
|
||||||
self.assertTrue((data["col_idx"] == [15, 1818, 1837]).all())
|
self.assertTrue((data["col_idx"] == [15, 1818, 1837]).all())
|
||||||
|
|
||||||
|
def test_compute_embedding(self):
|
||||||
|
filter = {"obs": {"index": [[0, 100]]}}
|
||||||
|
|
||||||
|
# Verify that we correctly handle the case where we lack scanpy
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
with unittest.mock.patch.dict(sys.modules, {"scanpy": None}):
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
self.data.compute_embedding("umap", filter)
|
||||||
|
|
||||||
|
# if we happen to have scanpy, test the full API, else punt
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
scanpy_spec = importlib.util.find_spec("scanpy")
|
||||||
|
if scanpy_spec is None:
|
||||||
|
print("Skipping compute_embedding test as ScanPy not installed")
|
||||||
|
return
|
||||||
|
|
||||||
|
# this feature is unsupported in backed mode, and we expect an error
|
||||||
|
if self.data.data.isbacked:
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
self.data.compute_embedding("umap", filter)
|
||||||
|
return
|
||||||
|
|
||||||
|
(schema, fbs) = self.data.compute_embedding("umap", filter)
|
||||||
|
|
||||||
|
self.assertIsInstance(schema["name"], str)
|
||||||
|
name = schema["name"]
|
||||||
|
self.assertEqual(schema["type"], "float32")
|
||||||
|
self.assertEqual(schema["dims"], [f"{name}_0", f"{name}_1"])
|
||||||
|
|
||||||
|
emb = decode_fbs.decode_matrix_FBS(fbs)
|
||||||
|
self.assertEqual(emb["n_rows"], 100)
|
||||||
|
self.assertEqual(emb["n_cols"], 2)
|
||||||
|
self.assertEqual(emb["col_idx"], [f"{name}_0", f"{name}_1"])
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class EndPoints(object):
|
|||||||
result_data = result.json()
|
result_data = result.json()
|
||||||
self.assertIn("library_versions", result_data["config"])
|
self.assertIn("library_versions", result_data["config"])
|
||||||
self.assertEqual(result_data["config"]["displayNames"]["dataset"], "pbmc3k")
|
self.assertEqual(result_data["config"]["displayNames"]["dataset"], "pbmc3k")
|
||||||
self.assertEqual(len(result_data["config"]["features"]), 3)
|
self.assertEqual(len(result_data["config"]["features"]), 5)
|
||||||
|
|
||||||
def test_get_layout_fbs(self):
|
def test_get_layout_fbs(self):
|
||||||
endpoint = "layout/obs"
|
endpoint = "layout/obs"
|
||||||
|
|||||||
@@ -136,3 +136,20 @@ class WritableAnnotationTest(unittest.TestCase):
|
|||||||
all_col_schema["cat_B"],
|
all_col_schema["cat_B"],
|
||||||
{"name": "cat_B", "type": "categorical", "categories": ["label_B"], "writable": True},
|
{"name": "cat_B", "type": "categorical", "categories": ["label_B"], "writable": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_config(self):
|
||||||
|
features = self.data.get_features(self.annotations)
|
||||||
|
|
||||||
|
# test each for singular presence and accuracy of available flag
|
||||||
|
def check_feature(method, path, available):
|
||||||
|
feature = list(
|
||||||
|
filter(lambda f: f.method == method and f.path == path and f.available == available, features)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(feature)
|
||||||
|
self.assertEqual(len(feature), 1)
|
||||||
|
|
||||||
|
check_feature("POST", "/cluster/", False)
|
||||||
|
check_feature("POST", "/diffexp/", not self.data.config.disable_diffexp)
|
||||||
|
check_feature("GET", "/layout/obs", True)
|
||||||
|
check_feature("PUT", "/layout/obs", self.data.config.enable_reembedding)
|
||||||
|
check_feature("PUT", "/annotations/obs", True)
|
||||||
|
|||||||
Reference in New Issue
Block a user