diff --git a/.gitignore b/.gitignore index cd52f01f..c72102cb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,33 @@ npm-debug.log .vscode data + + +*.idea* + +__pycache__ +*.DS_Store* + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml + +GBM +venv +extesting + +Dockerfile-* +*-data/* +data/* +runServer.py +templates/favicon.png +templates/index.html +templates/service-worker.js +templates/static/* +Graph.dot* + +server/app/web/static/css/ +server/app/web/static/img/ +server/app/web/static/js/ +server/app/web/templates/index\.html diff --git a/README.md b/README.md index 6a645a93..0b9ab3b5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A React + Redux web application for exploring large scale single cell RNA sequence data. ##### Quickstart: - +* `cd client` * `npm install` * `npm start` -* `localhost:3000` +* `localhost:3000` \ No newline at end of file diff --git a/client/__tests__/util/bitArray.test.js b/client/__tests__/util/bitArray.test.js new file mode 100644 index 00000000..72e91882 --- /dev/null +++ b/client/__tests__/util/bitArray.test.js @@ -0,0 +1,184 @@ +// jshint esversion: 6 + +const BitArray = require("../../src/util/typedCrossfilter/bitArray"); +const defaultTestLength = 8; + +describe("default select state", () => { + test("newly created Bitarray should be deselected", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.freeDimension(dim); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + }); +}); + +describe("select and deselect", () => { + test("selectAll and deselectAll", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim1 = ba.allocDimension(); + expect(dim1).toBeDefined(); + ba.selectAll(dim1); + + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + const dim2 = ba.allocDimension(); + expect(dim2).toBeDefined(); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.deselectAll(dim1); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.deselectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim1); + ba.selectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.freeDimension(dim1); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.freeDimension(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + }); + + test("selectOne and deselectOne", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + + ba.selectOne(dim, 0); + expect(ba.isSelected(0)).toEqual(true); + for (let i = 1; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.deselectOne(dim, 0); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectOne(dim, 1); + expect(ba.isSelected(1)).toEqual(true); + expect(ba.isSelected(0)).toEqual(false); + for (let i = 2; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim); + ba.deselectOne(dim, defaultTestLength - 1); + expect(ba.isSelected(defaultTestLength - 1)).toEqual(false); + for (let i = 0; i < defaultTestLength - 1; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + }); +}); + +describe("selectionCount", () => { + test("simple", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim1 = ba.allocDimension(); + expect(dim1).toBeDefined(); + const dim2 = ba.allocDimension(); + expect(dim2).toBeDefined(); + + expect(ba.selectionCount).toEqual(0); + ba.selectAll(dim1); + expect(ba.selectionCount).toEqual(0); + ba.selectAll(dim2); + expect(ba.selectionCount).toEqual(defaultTestLength); + + for (let i = 0; i < defaultTestLength; i++) { + ba.deselectOne(dim1, i); + expect(ba.selectionCount).toEqual(defaultTestLength - i - 1); + expect(ba.selectionCount).toEqual(ba.countAllOnes()); + } + + ba.freeDimension(dim1); + ba.freeDimension(dim2); + }); +}); + +describe("fillBySelection", () => { + test("sets values correctly", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + + const arr = new Int32Array(defaultTestLength); + arr.fill(0); + const truth = new Int32Array(defaultTestLength); + truth.fill(0); + + // initial state should be deselected + ba.fillBySelection(arr, 1, 0); + expect(arr).toEqual(expect.not.arrayContaining([1])); + + // selectAll + ba.selectAll(dim); + ba.fillBySelection(arr, 1, 0); + expect(arr).toEqual(expect.not.arrayContaining([0])); + + // deselectOne + ba.deselectOne(dim, 3); + ba.fillBySelection(arr, 1, 0); + truth.fill(1); + truth[3] = 0; + expect(arr).toEqual(truth); + + // deselectAll + ba.deselectAll(dim); + ba.fillBySelection(arr, 1, 0); + truth.fill(0); + expect(arr).toEqual(truth); + + // selectOne + ba.selectOne(dim, 5); + ba.fillBySelection(arr, 6, 1); + truth.fill(1); + truth[5] = 6; + expect(arr).toEqual(truth); + + // should be deselected after dimension disposal + ba.freeDimension(dim); + ba.fillBySelection(arr, 3, 9); + truth.fill(9); + expect(arr).toEqual(truth); + }); +}); diff --git a/client/__tests__/util/positiveInterval.test.js b/client/__tests__/util/positiveInterval.test.js new file mode 100644 index 00000000..81c44496 --- /dev/null +++ b/client/__tests__/util/positiveInterval.test.js @@ -0,0 +1,140 @@ +// jshint esversion: 6 + +const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals"); + +describe("canonicalize", () => { + test("empty", () => { + expect(PositiveIntervals.canonicalize([])).toEqual([]); + }); + + test("simple, already correct", () => { + expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]); + expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([ + [0, 1], + [2, 3] + ]); + }); + + test("non-canonical, need to be canonicalized", () => { + expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]); + expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]); + }); +}); + +describe("union", () => { + test("empty range", () => { + expect(PositiveIntervals.union([], [])).toEqual([]); + expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]); + expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]); + expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]); + expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]); + }); + + test("simple ranges", () => { + expect(PositiveIntervals.union([[1, 2]], [[2, 3]])).toEqual([[1, 3]]); + expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]); + expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([ + [1, 2], + [3, 4] + ]); + expect( + PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]]) + ).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]); + expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([ + [1, 4] + ]); + expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([ + [3, 3], + [4, 4] + ]); + }); +}); + +describe("intersection", () => { + test("empty range", () => { + expect(PositiveIntervals.intersection([], [])).toEqual([]); + expect(PositiveIntervals.intersection([], [[1, 2]])).toEqual([]); + expect(PositiveIntervals.intersection([[1, 2]], [])).toEqual([]); + }); + + test("simple", () => { + expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]); + expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([ + [1, 10] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([ + [2, 8] + ]); + expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([ + [2, 8] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([ + [2, 10] + ]); + expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([ + [2, 10] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([ + [1, 8] + ]); + expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([ + [1, 8] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual( + [[1, 2], [6, 9]] + ); + expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual( + [[1363, 2638]] + ); + expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([ + [1, 2] + ]); + }); +}); + +describe("difference", () => { + test("empty", () => { + expect(PositiveIntervals.difference([], [])).toEqual([]); + expect(PositiveIntervals.difference([], [[1, 10]])).toEqual([]); + expect(PositiveIntervals.difference([[1, 10]], [])).toEqual([[1, 10]]); + }); + + test("simple", () => { + expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([ + [1, 2], + [3, 5] + ]); + expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([ + [5, 10] + ]); + expect( + PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]]) + ).toEqual([[1363, 2055]]); + expect( + PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]]) + ).toEqual([]); + expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([ + [1, 10] + ]); + expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([ + [0, 1], + [2, 10] + ]); + expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([ + [0, 9] + ]); + }); +}); diff --git a/client/__tests__/util/typedCrossfilter.test.js b/client/__tests__/util/typedCrossfilter.test.js new file mode 100644 index 00000000..00e68495 --- /dev/null +++ b/client/__tests__/util/typedCrossfilter.test.js @@ -0,0 +1,276 @@ +// jshint esversion: 6 +const _ = require("lodash"); +const crossfilter = require("../../src/util/typedCrossfilter"); + +const someData = [ + { + date: "2011-11-14T16:17:54Z", + quantity: 2, + total: 190, + tip: 100, + type: "tab", + productIDs: ["001"] + }, + { + date: "2011-11-14T16:20:19Z", + quantity: 2, + total: 190, + tip: 100, + type: "tab", + productIDs: ["001", "005"] + }, + { + date: "2011-11-14T16:28:54Z", + quantity: 1, + total: 300, + tip: 200, + type: "visa", + productIDs: ["004", "005"] + }, + { + date: "2011-11-14T16:30:43Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "002"] + }, + { + date: "2011-11-14T16:48:46Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["005"] + }, + { + date: "2011-11-14T16:53:41Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "004", "005"] + }, + { + date: "2011-11-14T16:54:06Z", + quantity: 1, + total: 100, + tip: 0, + type: "cash", + productIDs: ["001", "002", "003", "004", "005"] + }, + { + date: "2011-11-14T16:58:03Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001"] + }, + { + date: "2011-11-14T17:07:21Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["004", "005"] + }, + { + date: "2011-11-14T17:22:59Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "002", "004", "005"] + }, + { + date: "2011-11-14T17:25:45Z", + quantity: 2, + total: 200, + tip: 0, + type: "cash", + productIDs: ["002"] + }, + { + date: "2011-11-14T17:29:52Z", + quantity: 1, + total: 200, + tip: 100, + type: "visa", + productIDs: ["004"] + } +]; + +var payments = null; +beforeEach(() => { + payments = crossfilter(someData); +}); + +describe("typedCrossfilter", () => { + test("alloc and free", () => { + expect(payments).toBeDefined(); + expect(payments.size()).toEqual(someData.length); + expect(payments.all()).toEqual(someData); + + const quantity = payments.dimension(r => r.quantity, Int32Array); + expect(quantity).toBeDefined(); + expect(quantity.id()).toBeDefined(); + + quantity.dispose(); + expect(payments.size()).toEqual(someData.length); + expect(payments.all()).toEqual(someData); + }); + + test("filterAll and filterNone", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + expect(quantity).toBeDefined(); + expect(tip).toBeDefined(); + expect(total).toBeDefined(); + expect(type).toBeDefined(); + + // initially, all should be filtered + expect(payments.allFiltered().length).toEqual(payments.size()); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // filterAll + tip.filterAll(); // should change nothing + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // ditto + total.filterAll(); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // filterNone + type.filterNone(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + quantity.filterNone(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + // invert the first none; should have no effect because type is + // still not filtered + quantity.filterAll(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + // filter all of type; should select all + type.filterAll(); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(payments.size()); + }); + + test("filterExact", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + quantity.filterExact(1); + expect(payments.countFiltered()).toEqual( + _.countBy(someData, "quantity")[1] + ); + expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 })); + + tip.filterExact(0); + expect(payments.allFiltered()).toEqual( + _.filter(someData, { tip: 0, quantity: 1 }) + ); + + type.filterExact("cash"); + expect(payments.allFiltered()).toEqual( + _.filter(someData, { tip: 0, quantity: 1, type: "cash" }) + ); + }); + + test("filterRange", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + tip.filterRange([0, 91]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 0 && r.tip < 91) + .value() + ); + + tip.filterRange([0, 90]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 0 && r.tip < 90) + .value() + ); + + tip.filterRange([1, 90]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 1 && r.tip < 91) + .value() + ); + }); + + test("filterEnum", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + type.filterEnum(["tab", "cash"]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.type === "cash" || r.type === "tab") + .value() + ); + + tip.filterEnum([0, 100]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.type === "cash" || r.type === "tab") + .filter(r => r.tip === 0 || r.tip === 100) + .value() + ); + }); + + test("more than 32 dimensions", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + // Create a bunch of fake dimensions to ensure we can handle > 32 + let dimMap = {}; + for (let i = 0; i < 65; i++) { + dimMap[i] = payments.dimension(r => Math.random(), Float32Array); + expect(dimMap[i]).toBeDefined(); + expect(dimMap[i].id()).toBeDefined(); + } + + // everything should start as selected/filtered + expect(payments.countFiltered()).toEqual(someData.length); + + dimMap[0].filterAll(); + dimMap[64].filterAll(); + expect(payments.countFiltered()).toEqual(someData.length); + + dimMap[33].filterNone(); + expect(payments.allFiltered()).toEqual([]); + + dimMap[33].filterAll(); + expect(payments.allFiltered()).toEqual(someData); + }); +}); diff --git a/configuration/babel/babel.dev.js b/client/configuration/babel/babel.dev.js similarity index 100% rename from configuration/babel/babel.dev.js rename to client/configuration/babel/babel.dev.js diff --git a/configuration/babel/babel.prod.js b/client/configuration/babel/babel.prod.js similarity index 100% rename from configuration/babel/babel.prod.js rename to client/configuration/babel/babel.prod.js diff --git a/configuration/babel/babel.test.js b/client/configuration/babel/babel.test.js similarity index 100% rename from configuration/babel/babel.test.js rename to client/configuration/babel/babel.test.js diff --git a/configuration/eslint/eslint.js b/client/configuration/eslint/eslint.js similarity index 100% rename from configuration/eslint/eslint.js rename to client/configuration/eslint/eslint.js diff --git a/configuration/polyfills/polyfills.js b/client/configuration/polyfills/polyfills.js similarity index 100% rename from configuration/polyfills/polyfills.js rename to client/configuration/polyfills/polyfills.js diff --git a/configuration/webpack/webpack.config.dev.js b/client/configuration/webpack/webpack.config.dev.js similarity index 100% rename from configuration/webpack/webpack.config.dev.js rename to client/configuration/webpack/webpack.config.dev.js diff --git a/configuration/webpack/webpack.config.prod.js b/client/configuration/webpack/webpack.config.prod.js similarity index 100% rename from configuration/webpack/webpack.config.prod.js rename to client/configuration/webpack/webpack.config.prod.js diff --git a/favicon.png b/client/favicon.png similarity index 100% rename from favicon.png rename to client/favicon.png diff --git a/index.html b/client/index.html similarity index 100% rename from index.html rename to client/index.html diff --git a/index_template.html b/client/index_template.html similarity index 100% rename from index_template.html rename to client/index_template.html diff --git a/package.json b/client/package.json similarity index 97% rename from package.json rename to client/package.json index 051f5f6b..1d458a9d 100644 --- a/package.json +++ b/client/package.json @@ -115,6 +115,8 @@ "whatwg-fetch": "^2.0.1" }, "jest": { - "testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"] + "testMatch": [ + "**/__tests__/**/?(*.)(spec|test).js?(x)" + ] } } diff --git a/server/development.js b/client/server/development.js similarity index 100% rename from server/development.js rename to client/server/development.js diff --git a/server/production.js b/client/server/production.js similarity index 100% rename from server/production.js rename to client/server/production.js diff --git a/server/utils.js b/client/server/utils.js similarity index 100% rename from server/utils.js rename to client/server/utils.js diff --git a/src/actions/index.js b/client/src/actions/index.js similarity index 100% rename from src/actions/index.js rename to client/src/actions/index.js diff --git a/src/components/_experimentalWebGLHeatmap/heatmap.js b/client/src/components/_experimentalWebGLHeatmap/heatmap.js similarity index 100% rename from src/components/_experimentalWebGLHeatmap/heatmap.js rename to client/src/components/_experimentalWebGLHeatmap/heatmap.js diff --git a/src/components/_experimentalWebGLHeatmap/orbit-control.js b/client/src/components/_experimentalWebGLHeatmap/orbit-control.js similarity index 100% rename from src/components/_experimentalWebGLHeatmap/orbit-control.js rename to client/src/components/_experimentalWebGLHeatmap/orbit-control.js diff --git a/src/components/_experimentalWebGLHeatmap/popup.js b/client/src/components/_experimentalWebGLHeatmap/popup.js similarity index 100% rename from src/components/_experimentalWebGLHeatmap/popup.js rename to client/src/components/_experimentalWebGLHeatmap/popup.js diff --git a/src/components/app.js b/client/src/components/app.js similarity index 100% rename from src/components/app.js rename to client/src/components/app.js diff --git a/src/components/categorical/categorical.css b/client/src/components/categorical/categorical.css similarity index 100% rename from src/components/categorical/categorical.css rename to client/src/components/categorical/categorical.css diff --git a/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js similarity index 100% rename from src/components/categorical/categorical.js rename to client/src/components/categorical/categorical.js diff --git a/src/components/categorical/util.js b/client/src/components/categorical/util.js similarity index 100% rename from src/components/categorical/util.js rename to client/src/components/categorical/util.js diff --git a/src/components/categorical/value.js b/client/src/components/categorical/value.js similarity index 100% rename from src/components/categorical/value.js rename to client/src/components/categorical/value.js diff --git a/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js similarity index 100% rename from src/components/continuous/continuous.js rename to client/src/components/continuous/continuous.js diff --git a/src/components/continuous/drawAxes.js b/client/src/components/continuous/drawAxes.js similarity index 100% rename from src/components/continuous/drawAxes.js rename to client/src/components/continuous/drawAxes.js diff --git a/src/components/continuous/drawLinesCanvas.js b/client/src/components/continuous/drawLinesCanvas.js similarity index 100% rename from src/components/continuous/drawLinesCanvas.js rename to client/src/components/continuous/drawLinesCanvas.js diff --git a/src/components/continuous/histogramBrush.js b/client/src/components/continuous/histogramBrush.js similarity index 100% rename from src/components/continuous/histogramBrush.js rename to client/src/components/continuous/histogramBrush.js diff --git a/src/components/continuous/parallel.js b/client/src/components/continuous/parallel.js similarity index 100% rename from src/components/continuous/parallel.js rename to client/src/components/continuous/parallel.js diff --git a/src/components/continuous/parallelCoordinates.css b/client/src/components/continuous/parallelCoordinates.css similarity index 100% rename from src/components/continuous/parallelCoordinates.css rename to client/src/components/continuous/parallelCoordinates.css diff --git a/src/components/continuous/setupParallelCoordinates.js b/client/src/components/continuous/setupParallelCoordinates.js similarity index 100% rename from src/components/continuous/setupParallelCoordinates.js rename to client/src/components/continuous/setupParallelCoordinates.js diff --git a/src/components/continuous/util.js b/client/src/components/continuous/util.js similarity index 100% rename from src/components/continuous/util.js rename to client/src/components/continuous/util.js diff --git a/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js similarity index 100% rename from src/components/continuousLegend/index.js rename to client/src/components/continuousLegend/index.js diff --git a/src/components/expression/cellSetButtons.js b/client/src/components/expression/cellSetButtons.js similarity index 100% rename from src/components/expression/cellSetButtons.js rename to client/src/components/expression/cellSetButtons.js diff --git a/src/components/expression/diffExpHeatmap.js b/client/src/components/expression/diffExpHeatmap.js similarity index 100% rename from src/components/expression/diffExpHeatmap.js rename to client/src/components/expression/diffExpHeatmap.js diff --git a/src/components/expression/expression.css b/client/src/components/expression/expression.css similarity index 100% rename from src/components/expression/expression.css rename to client/src/components/expression/expression.css diff --git a/src/components/expression/expressionButtons.js b/client/src/components/expression/expressionButtons.js similarity index 100% rename from src/components/expression/expressionButtons.js rename to client/src/components/expression/expressionButtons.js diff --git a/src/components/framework/buttons.css b/client/src/components/framework/buttons.css similarity index 100% rename from src/components/framework/buttons.css rename to client/src/components/framework/buttons.css diff --git a/src/components/framework/container.css b/client/src/components/framework/container.css similarity index 100% rename from src/components/framework/container.css rename to client/src/components/framework/container.css diff --git a/src/components/framework/container.js b/client/src/components/framework/container.js similarity index 100% rename from src/components/framework/container.js rename to client/src/components/framework/container.js diff --git a/src/components/framework/header.css b/client/src/components/framework/header.css similarity index 100% rename from src/components/framework/header.css rename to client/src/components/framework/header.css diff --git a/src/components/framework/header.js b/client/src/components/framework/header.js similarity index 100% rename from src/components/framework/header.js rename to client/src/components/framework/header.js diff --git a/src/components/framework/sectionHeader.js b/client/src/components/framework/sectionHeader.js similarity index 100% rename from src/components/framework/sectionHeader.js rename to client/src/components/framework/sectionHeader.js diff --git a/src/components/graph/drawPointsRegl.js b/client/src/components/graph/drawPointsRegl.js similarity index 100% rename from src/components/graph/drawPointsRegl.js rename to client/src/components/graph/drawPointsRegl.js diff --git a/src/components/graph/graph.css b/client/src/components/graph/graph.css similarity index 100% rename from src/components/graph/graph.css rename to client/src/components/graph/graph.css diff --git a/src/components/graph/graph.js b/client/src/components/graph/graph.js similarity index 100% rename from src/components/graph/graph.js rename to client/src/components/graph/graph.js diff --git a/src/components/graph/setupSVGandBrush.js b/client/src/components/graph/setupSVGandBrush.js similarity index 100% rename from src/components/graph/setupSVGandBrush.js rename to client/src/components/graph/setupSVGandBrush.js diff --git a/src/components/graph/util.js b/client/src/components/graph/util.js similarity index 100% rename from src/components/graph/util.js rename to client/src/components/graph/util.js diff --git a/src/components/joy/drawJoy.js b/client/src/components/joy/drawJoy.js similarity index 100% rename from src/components/joy/drawJoy.js rename to client/src/components/joy/drawJoy.js diff --git a/src/components/joy/joy.css b/client/src/components/joy/joy.css similarity index 100% rename from src/components/joy/joy.css rename to client/src/components/joy/joy.css diff --git a/src/components/joy/joy.js b/client/src/components/joy/joy.js similarity index 100% rename from src/components/joy/joy.js rename to client/src/components/joy/joy.js diff --git a/src/components/joy/joyParser.js b/client/src/components/joy/joyParser.js similarity index 100% rename from src/components/joy/joyParser.js rename to client/src/components/joy/joyParser.js diff --git a/src/components/leftsidebar.js b/client/src/components/leftsidebar.js similarity index 100% rename from src/components/leftsidebar.js rename to client/src/components/leftsidebar.js diff --git a/src/components/scatterplot/drawPointsRegl.js b/client/src/components/scatterplot/drawPointsRegl.js similarity index 100% rename from src/components/scatterplot/drawPointsRegl.js rename to client/src/components/scatterplot/drawPointsRegl.js diff --git a/src/components/scatterplot/scatterplot.css b/client/src/components/scatterplot/scatterplot.css similarity index 100% rename from src/components/scatterplot/scatterplot.css rename to client/src/components/scatterplot/scatterplot.css diff --git a/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js similarity index 100% rename from src/components/scatterplot/scatterplot.js rename to client/src/components/scatterplot/scatterplot.js diff --git a/src/components/scatterplot/setupScatterplot.js b/client/src/components/scatterplot/setupScatterplot.js similarity index 100% rename from src/components/scatterplot/setupScatterplot.js rename to client/src/components/scatterplot/setupScatterplot.js diff --git a/src/components/scatterplot/util.js b/client/src/components/scatterplot/util.js similarity index 100% rename from src/components/scatterplot/util.js rename to client/src/components/scatterplot/util.js diff --git a/src/globals.js b/client/src/globals.js similarity index 100% rename from src/globals.js rename to client/src/globals.js diff --git a/src/index.css b/client/src/index.css similarity index 100% rename from src/index.css rename to client/src/index.css diff --git a/src/index.js b/client/src/index.js similarity index 100% rename from src/index.js rename to client/src/index.js diff --git a/src/middleware/updateCellColors.js b/client/src/middleware/updateCellColors.js similarity index 100% rename from src/middleware/updateCellColors.js rename to client/src/middleware/updateCellColors.js diff --git a/src/middleware/updateCellSelectionMiddleware.js b/client/src/middleware/updateCellSelectionMiddleware.js similarity index 100% rename from src/middleware/updateCellSelectionMiddleware.js rename to client/src/middleware/updateCellSelectionMiddleware.js diff --git a/src/middleware/updateURLMiddleware.js b/client/src/middleware/updateURLMiddleware.js similarity index 100% rename from src/middleware/updateURLMiddleware.js rename to client/src/middleware/updateURLMiddleware.js diff --git a/src/reducers/cells.js b/client/src/reducers/cells.js similarity index 100% rename from src/reducers/cells.js rename to client/src/reducers/cells.js diff --git a/src/reducers/controls.js b/client/src/reducers/controls.js similarity index 99% rename from src/reducers/controls.js rename to client/src/reducers/controls.js index c328e622..2160268a 100644 --- a/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -2,7 +2,7 @@ import _ from "lodash"; import { parseRGB } from "../util/parseRGB"; import { createSchemaByDataSniffing } from "../util/schema"; -var crossfilter = require("../util/typedCrossfilter"); +import crossfilter from "../util/typedCrossfilter"; // Deduce the correct crossfilter dimension type from a metadata // schema description. diff --git a/src/reducers/differential.js b/client/src/reducers/differential.js similarity index 100% rename from src/reducers/differential.js rename to client/src/reducers/differential.js diff --git a/src/reducers/expression.js b/client/src/reducers/expression.js similarity index 100% rename from src/reducers/expression.js rename to client/src/reducers/expression.js diff --git a/src/reducers/index.js b/client/src/reducers/index.js similarity index 100% rename from src/reducers/index.js rename to client/src/reducers/index.js diff --git a/src/reducers/initialize.js b/client/src/reducers/initialize.js similarity index 100% rename from src/reducers/initialize.js rename to client/src/reducers/initialize.js diff --git a/src/reducers/responsive.js b/client/src/reducers/responsive.js similarity index 100% rename from src/reducers/responsive.js rename to client/src/reducers/responsive.js diff --git a/src/util/camera.js b/client/src/util/camera.js similarity index 100% rename from src/util/camera.js rename to client/src/util/camera.js diff --git a/src/util/parseRGB.js b/client/src/util/parseRGB.js similarity index 100% rename from src/util/parseRGB.js rename to client/src/util/parseRGB.js diff --git a/src/util/renderQueue.js b/client/src/util/renderQueue.js similarity index 100% rename from src/util/renderQueue.js rename to client/src/util/renderQueue.js diff --git a/src/util/scaleLinear.js b/client/src/util/scaleLinear.js similarity index 100% rename from src/util/scaleLinear.js rename to client/src/util/scaleLinear.js diff --git a/src/util/scaleRGB.js b/client/src/util/scaleRGB.js similarity index 100% rename from src/util/scaleRGB.js rename to client/src/util/scaleRGB.js diff --git a/src/util/schema.js b/client/src/util/schema.js similarity index 100% rename from src/util/schema.js rename to client/src/util/schema.js diff --git a/src/util/typedCrossfilter/bitArray.js b/client/src/util/typedCrossfilter/bitArray.js similarity index 96% rename from src/util/typedCrossfilter/bitArray.js rename to client/src/util/typedCrossfilter/bitArray.js index 49258aca..9e4f3a5a 100644 --- a/src/util/typedCrossfilter/bitArray.js +++ b/client/src/util/typedCrossfilter/bitArray.js @@ -226,4 +226,4 @@ class BitArray { } } -module.exports = BitArray; +export default BitArray; diff --git a/src/util/typedCrossfilter/index.js b/client/src/util/typedCrossfilter/index.js similarity index 89% rename from src/util/typedCrossfilter/index.js rename to client/src/util/typedCrossfilter/index.js index 48cc0425..b2d38db4 100644 --- a/src/util/typedCrossfilter/index.js +++ b/client/src/util/typedCrossfilter/index.js @@ -29,9 +29,9 @@ more complex API. In a few cases, elements of that API were incorporated. */ -var PositiveIntervals = require("./positiveIntervals"); -var BitArray = require("./bitArray"); -var Util = require("./util"); +import PositiveIntervals from "./positiveIntervals"; +import BitArray from "./bitArray"; +import {fillRange, lowerBound, lowerBoundIndirect, upperBound, upperBoundIndirect} from "./util"; class TypedCrossfilter { constructor(data) { @@ -118,7 +118,7 @@ class ScalarDimension { this.value = array; // create sort index - this.index = Util.fillRange(new Uint32Array(this.crossfilter.data.length)); + this.index = fillRange(new Uint32Array(this.crossfilter.data.length)); this.index.sort((a, b) => array[a] - array[b]); } @@ -179,14 +179,14 @@ class ScalarDimension { // filter by value - exact match filterExact(value) { const newFilter = [ - Util.lowerBoundIndirect( + lowerBoundIndirect( this.value, this.index, value, 0, this.value.length ), - Util.upperBoundIndirect( + upperBoundIndirect( this.value, this.index, value, @@ -207,14 +207,14 @@ class ScalarDimension { const newFilter = []; for (let v = 0, len = values.length; v < len; v++) { const intv = [ - Util.lowerBoundIndirect( + lowerBoundIndirect( this.value, this.index, values[v], 0, this.value.length ), - Util.upperBoundIndirect( + upperBoundIndirect( this.value, this.index, values[v], @@ -233,14 +233,14 @@ class ScalarDimension { filterRange(range) { const newFilter = []; const intv = [ - Util.lowerBoundIndirect( + lowerBoundIndirect( this.value, this.index, range[0], 0, this.value.length ), - Util.upperBoundIndirect( + upperBoundIndirect( this.value, this.index, range[1], @@ -350,7 +350,7 @@ class EnumDimension extends ScalarDimension { const enumLen = this.enumIndex.length; for (let i = 0; i < len; i++) { const v = value(data[i]); - const e = Util.lowerBound(this.enumIndex, v, 0, enumLen); + const e = lowerBound(this.enumIndex, v, 0, enumLen); array[i] = e; } return array; @@ -358,14 +358,14 @@ class EnumDimension extends ScalarDimension { filterExact(value) { return super.filterExact( - Util.lowerBound(this.enumIndex, value, 0, this.enumIndex.length) + lowerBound(this.enumIndex, value, 0, this.enumIndex.length) ); } filterEnum(values) { return super.filterEnum( values.map(v => - Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length) + lowerBound(this.enumIndex, v, 0, this.enumIndex.length) ) ); } @@ -373,7 +373,7 @@ class EnumDimension extends ScalarDimension { filterRange(range) { return super.filterEnum( range.map(v => - Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length) + lowerBound(this.enumIndex, v, 0, this.enumIndex.length) ) ); } @@ -391,4 +391,4 @@ crossfilter.TypedCrossfilter = TypedCrossfilter; crossfilter.ScalarDimension = ScalarDimension; crossfilter.EnumDimension = EnumDimension; -module.exports = crossfilter; +export default crossfilter; diff --git a/src/util/typedCrossfilter/positiveIntervals.js b/client/src/util/typedCrossfilter/positiveIntervals.js similarity index 95% rename from src/util/typedCrossfilter/positiveIntervals.js rename to client/src/util/typedCrossfilter/positiveIntervals.js index 6e5d074a..5e54bf7f 100644 --- a/src/util/typedCrossfilter/positiveIntervals.js +++ b/client/src/util/typedCrossfilter/positiveIntervals.js @@ -129,4 +129,4 @@ class PositiveIntervals { } } -module.exports = PositiveIntervals; +export default PositiveIntervals; diff --git a/src/util/typedCrossfilter/util.js b/client/src/util/typedCrossfilter/util.js similarity index 81% rename from src/util/typedCrossfilter/util.js rename to client/src/util/typedCrossfilter/util.js index deffcdec..18b2c21e 100644 --- a/src/util/typedCrossfilter/util.js +++ b/client/src/util/typedCrossfilter/util.js @@ -8,7 +8,7 @@ // fill an array or typedarray with a sequential range of numbers, // starting with `start` // -function fillRange(arr, start = 0) { +export function fillRange(arr, start = 0) { for (let i = 0, len = arr.length; i < len; i++) { arr[i] = i + start; } @@ -30,7 +30,7 @@ function fillRange(arr, start = 0) { // a factory version of lowerBound that takes an accessor (rather than having // a special-cased version for lining the indirection). // -function lowerBound(valueArray, value, first, last) { +export function lowerBound(valueArray, value, first, last) { // this is just a binary search while (first < last) { const middle = (first + last) >>> 1; @@ -45,7 +45,7 @@ function lowerBound(valueArray, value, first, last) { // Inlined performance optimization - used to indirect through a sort map. // -function lowerBoundIndirect(valueArray, indexArray, value, first, last) { +export function lowerBoundIndirect(valueArray, indexArray, value, first, last) { // this is just a binary search while (first < last) { const middle = (first + last) >>> 1; @@ -69,7 +69,7 @@ function lowerBoundIndirect(valueArray, indexArray, value, first, last) { // C++: upper_bound() // Python: bisect.bisect_right() // -function upperBound(valueArray, value, first, last) { +export function upperBound(valueArray, value, first, last) { // this is just a binary search while (first < last) { const middle = (first + last) >>> 1; @@ -84,7 +84,7 @@ function upperBound(valueArray, value, first, last) { // Inline performance optimization // -function upperBoundIndirect(valueArray, indexArray, value, first, last) { +export function upperBoundIndirect(valueArray, indexArray, value, first, last) { // this is just a binary search while (first < last) { const middle = (first + last) >>> 1; @@ -96,11 +96,3 @@ function upperBoundIndirect(valueArray, indexArray, value, first, last) { } return first; } - -module.exports = { - fillRange, - lowerBound, - lowerBoundIndirect, - upperBound, - upperBoundIndirect -}; diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 00000000..92415348 --- /dev/null +++ b/server/app/__init__.py @@ -0,0 +1,55 @@ +import os + +from flask import Flask +from flask_compress import Compress +from flask_cors import CORS +from flask_restful_swagger_2 import get_swagger_blueprint + +from .web import webapp +from .rest_api.rest import get_api_resources + +app = Flask(__name__) +Compress(app) +CORS(app) + +# Config +CXG_DIR = os.environ.get("CXG_DIRECTORY", default="/Users/charlotteweaver/Documents/Git/cxg-v2/data/") +SECRET_KEY = os.environ.get("CXG_SECRET_KEY", default="SparkleAndShine") +ENGINE = os.environ.get("CXG_ENGINE", default="scanpy") +TITLE = os.environ.get("DATASET_TITLE", default="PBMC 3K") +# TODO remove the 2 when this is prod +CXG_API_BASE = os.environ.get("CXG_API_BASE2", default="http://0.0.0.0:5005/api/") + +app.config.update( + SECRET_KEY=SECRET_KEY, + CXG_API_BASE=CXG_API_BASE, + ENGINE=ENGINE, + DATA=CXG_DIR, + DATASET_TITLE=TITLE +) + +app.config["PROFILE"] = True +# app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[15]) + +# Application Data +data = None +if app.config["ENGINE"] == "scanpy": + from .scanpy_engine.scanpy_engine import ScanpyEngine + data = ScanpyEngine(app.config["DATA"], schema="data_schema.json") + +REACTIVE_LIMIT = 1_000_000 + +# A list of swagger document objects +docs = [] +resources = get_api_resources() +docs.append(resources.get_swagger_doc()) + + +app.register_blueprint(webapp.bp) +app.register_blueprint(resources.blueprint) +app.register_blueprint( + get_swagger_blueprint(docs, "/api/swagger", produces=["application/json"], title="cellxgene rest api", + description="An API connecting ExpressionMatrix2 clustering algorithm to cellxgene")) + + +app.add_url_rule("/", endpoint="index") diff --git a/server/app/driver/__init__.py b/server/app/driver/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/driver/driver.py b/server/app/driver/driver.py new file mode 100644 index 00000000..fc6aae12 --- /dev/null +++ b/server/app/driver/driver.py @@ -0,0 +1,81 @@ +from abc import ABCMeta, abstractmethod + + +class CXGDriver(metaclass=ABCMeta): + def __init__(self, data, schema=None, graph_method=None, diffexp_method=None): + self.data = self._load_data(data) + + @staticmethod + @abstractmethod + def _load_data(data): + pass + + @abstractmethod + def _load_or_infer_schema(data): + pass + + @abstractmethod + def cells(self): + pass + + @abstractmethod + def genes(self): + pass + + @abstractmethod + def filter_cells(self, filter): + """ + Filter cells from data and return a subset of the data + A filter is a dictionary where the key is a metadatata category + Value is dictionary + value_type: int, float, string + variable_type: continuous, categorical + query: filter value, for categorical [val1, val2], for continuous {min: x, max:y} + Filters are combined with the and operator + :param filter: + :return: filtered dataframe + """ + pass + + @abstractmethod + def metadata(self, df, fields=None): + """ + Gets metadata key:value for each cells + + :param df: from filter_cells, dataframe + :param fields: list of keys for metadata to return, returns all metadata values if not set. + :return: list of metadata values + """ + pass + + @abstractmethod + def create_graph(self, df): + """ + Computes a n-d layout for cells through dimensionality reduction. + :param df: from filter_cells, dataframe + :return: [cellid, x, y] + """ + pass + + @abstractmethod + def diffexp(self, df1, df2): + """ + Computes the top differentially expressed genes between two clusters + :param df1: from filter_cells, dataframe containing first set of cells + :param df2: from filter_cells, dataframe containing second set of cells + :return: top genes, stats and expression values for top genes + """ + pass + + @abstractmethod + def expression(self, df): + """ + Retrieves expression for each gene for cells in data frame + :param df: + :return: { + "genes": list of genes, + "cells": list of cells and expression list, + "nonzero_gene_count": number of nonzero genes + } + """ + pass diff --git a/server/app/rest_api/__init__.py b/server/app/rest_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/rest_api/rest.py b/server/app/rest_api/rest.py new file mode 100644 index 00000000..bc15c44d --- /dev/null +++ b/server/app/rest_api/rest.py @@ -0,0 +1,439 @@ +from flask import ( + Blueprint, request +) +from flask_restful_swagger_2 import Api, swagger, Resource + +from ..util.utils import make_payload +from ..util.filter import parse_filter + + +class InitializeAPI(Resource): + @swagger.doc({ + "summary": "get metadata schema, ranges for values, and cell count to initialize cellxgene app", + "tags": ["initialize"], + "parameters": [], + "responses": { + "200": { + "description": "initialization data for UI", + "examples": { + "application/json": { + "data": { + "cellcount": 3589, + "options": { + "Sample.type": { + "options": { + "Glioblastoma": 3589 + } + }, + "Selection": { + "options": { + "Astrocytes(HEPACAM)": 714, + "Endothelial(BSC)": 123, + "Microglia(CD45)": 1108, + "Neurons(Thy1)": 685, + "Oligodendrocytes(GC)": 294, + "Unpanned": 665 + } + }, + "Splice_sites_AT.AC": { + "range": { + "max": 1025, + "min": 152 + } + }, + "Splice_sites_Annotated": { + "range": { + "max": 1075869, + "min": 26 + } + } + }, + "schema": { + "CellName": { + "displayname": "Name", + "type": "string", + "variabletype": "categorical" + }, + "Class": { + "displayname": "Class", + "type": "string", + "variabletype": "categorical" + }, + "ERCC_reads": { + "displayname": "ERCC Reads", + "type": "int", + "variabletype": "continuous" + }, + "ERCC_to_non_ERCC": { + "displayname": "ERCC:Non-ERCC", + "type": "float", + "variabletype": "continuous" + }, + "Genes_detected": { + "displayname": "Genes Detected", + "type": "int", + "variabletype": "continuous" + } + }, + "genes": ["1/2-SBSRNA4", "A1BG", "A1BG-AS1"] + + }, + "status": { + "error": False, + "errormessage": "" + } + } + } + } + } + }) + def get(self): + from app import data, REACTIVE_LIMIT + return make_payload({ + "schema": data.schema, + "cellcount": data.cell_count, + "reactivelimit": REACTIVE_LIMIT, + "genes": data.genes(), + "ranges": data.metadata_ranges(), + + }) + + +class CellsAPI(Resource): + @swagger.doc({ + "summary": "filter based on metadata fields to get a subset cells, expression data, and metadata", + "tags": ["cells"], + "description": "Cells takes query parameters defined in the schema retrieved from the /initialize enpoint. " + "
For categorical metadata keys filter based on `key=value`
" + " For continuous metadata keys filter by `key=min,max`
Either value " + "can be replaced by a \*. To have only a minimum value `key=min,\*` To have only a maximum " + "value `key=\*,max`
Graph data (if retrieved) is normalized" + " To only retrieve cells that don't have a value for the key filter by `key`", + "parameters": [], + + "responses": { + "200": { + "description": "initialization data for UI", + "examples": { + "application/json": { + "data": { + "badmetadatacount": 0, + "cellcount": 0, + "cellids": ["..."], + "metadata": [ + { + "CellName": "1001000173.G8", + "Class": "Neoplastic", + "Cluster_2d": "11", + "Cluster_2d_color": "#8C564B", + "Cluster_CNV": "1", + "Cluster_CNV_color": "#1F77B4", + "ERCC_reads": "152104", + "ERCC_to_non_ERCC": "0.562454470489481", + "Genes_detected": "1962", + "Location": "Tumor", + "Location.color": "#FF7F0E", + "Multimapping_reads_percent": "2.67", + "Neoplastic": "Neoplastic", + "Non_ERCC_reads": "270429", + "Sample.name": "BT_S2", + "Sample.name.color": "#AEC7E8", + "Sample.type": "Glioblastoma", + "Sample.type.color": "#1F77B4", + "Selection": "Unpanned", + "Selection.color": "#98DF8A", + "Splice_sites_AT.AC": "102", + "Splice_sites_Annotated": "122397", + "Splice_sites_GC.AG": "761", + "Splice_sites_GT.AG": "125741", + "Splice_sites_non_canonical": "56", + "Splice_sites_total": "126660", + "Total_reads": "1741039", + "Unique_reads": "1400382", + "Unique_reads_percent": "80.43", + "Unmapped_mismatch": "2.15", + "Unmapped_other": "0.18", + "Unmapped_short": "14.56", + "housekeeping_cluster": "2", + "housekeeping_cluster_color": "#AEC7E8", + "recluster_myeloid": "NA", + "recluster_myeloid_color": "NA" + }, + ], + "reactive": True, + "graph": [ + [ + "1001000173.G8", + 0.93836, + 0.28623 + ], + + [ + "1001000173.D4", + 0.1662, + 0.79438 + ] + + ], + "status": { + "error": False, + "errormessage": "" + } + + }, + } + }, + }, + + "400": { + "description": "bad query params", + } + } + }) + def get(self): + from app import data + payload = { + "metadata": [], + "cellcount": 0, + "graph": [], + "ranges": {}, + } + # get query params + filter = parse_filter(request.args, data.schema) + filtered_data = data.filter_cells(filter) + payload["metadata"] = data.metadata(filtered_data) + payload["ranges"] = data.metadata_ranges(filtered_data) + payload["graph"] = data.create_graph(filtered_data) + payload["cellcount"] = data.cell_count + return make_payload(payload) + + +class ExpressionAPI(Resource): + @swagger.doc({ + "summary": "Json with gene list and expression data by cell, limited to first 40 cells", + "tags": ["expression"], + "parameters": [ + { + "name": "include_unexpressed_genes", + "description": "Include genes that have 0 expression across all cells in set", + "in": "path", + "type": "bool", + } + ], + "responses": { + "200": { + "description": "Json for heatmap", + "examples": { + "application/json": { + "data": { + "cells": [ + { + "cellname": "1/2-SBSRNA4", + "e": [0, 0, 214, 0, 0] + }, + ], + "genes": [ + "1001000173.G8", + "1001000173.D4", + "1001000173.B4", + "1001000173.A2", + "1001000173.E2" + ], + "nonzero_gene_count": 2857 + }, + "status": { + "error": False, + "errormessage": "" + } + } + } + } + } + }) + def get(self): + from app import data + expression_data = data.expression() + return make_payload(expression_data) + + @swagger.doc({ + "summary": "Json with gene list and expression data by cell", + "tags": ["expression"], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "example": { + "celllist": ["1001000173.G8", "1001000173.D4"], + "genelist": ["1/2-SBSRNA4", "A1BG", "A1BG-AS1", "A1CF", "A2LD1", "A2M", "A2ML1", "A2MP1", + "A4GALT"], + "include_unexpressed_genes": True, + } + + } + }, + ], + "responses": { + "200": { + "description": "Json for expressiondata", + "examples": { + "application/json": { + "data": { + "cells": [ + { + "cellname": "1001000173.D4", + "e": [0, 0] + }, + { + "cellname": "1001000173.G8", + "e": [0, 0] + } + ], + "genes": [ + "ABCD4", + "ZWINT" + ], + "nonzero_gene_count": 2857 + }, + "status": { + "error": False, + "errormessage": "" + } + + } + } + }, + "400": { + "description": "Required parameter missing/incorrect", + } + } + }) + def post(self): + from app import data + args = request.get_json() + cell_list = args.get("celllist", []) + gene_list = args.get("genelist", []) + if not cell_list and not gene_list: + return make_payload([], "must include celllist and/or genelist parameter", 400) + + expression_data = data.expression(cell_list, gene_list) + + if cell_list and len(expression_data["cells"]) < len(cell_list): + return make_payload([], "Some cell ids not available", 400) + if gene_list and len(expression_data["genes"]) < len(gene_list): + return make_payload([], "Some genes not available", 400) + + return make_payload(expression_data) + + +class DifferentialExpressionAPI(Resource): + @swagger.doc({ + "summary": "Get the top expressed genes for two cell sets. Calculated using t-test", + "tags": ["expression"], + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "example": { + "celllist1": ["1001000176.C12", "1001000176.C7", "1001000177.F11"], + "celllist2": ["1001000012.D2", "1001000017.F10", "1001000033.C3", "1001000229.D4"], + "num_genes": 5, + "pval": 0.000001, + }, + } + } + ], + "responses": { + "200": { + "description": "top expressed genes for cellset1, cellset2", + "examples": { + "application/json": { + "data": { + "celllist1": { + "ave_diff": [ + 432.0132935431362, + 12470.5623982637, + 957.0246880086814 + ], + "mean_expression_cellset1": [ + 438.6185567010309, + 13315.536082474227, + 1076.5773195876288 + ], + "mean_expression_cellset2": [ + 6.605263157894737, + 844.9736842105264, + 119.55263157894737 + ], + "pval": [ + 3.8906598089944563e-35, + 1.9086226376018916e-25, + 7.847480544069826e-21 + ], + "topgenes": [ + "TMSB10", + "FTL", + "TMSB4X" + ] + }, + "celllist2": { + "ave_diff": [ + -6860.599158979924, + -519.1314432989691, + -10278.328269126423 + ], + "mean_expression_cellset1": [ + 2.8350515463917527, + 0.6185567010309279, + 23.09278350515464 + ], + "mean_expression_cellset2": [ + 6863.434210526316, + 519.75, + 10301.421052631578 + ], + "pval": [ + 4.662891833748732e-44, + 3.6278087029927103e-37, + 8.396825170618402e-35 + ], + "topgenes": [ + "SPARCL1", + "C1orf61", + "CLU" + ] + } + }, + "status": { + "error": False, + "errormessage": "" + } + } + } + } + } + }) + def post(self): + from app import data + args = request.get_json() + cell_list_1 = args.get("celllist1", []) + cell_list_2 = args.get("celllist2", []) + num_genes = args.get("num_genes", 7) + pval = args.get("pval", 0.5) + if not (cell_list_1 and cell_list_2): + return make_payload([], + "must include celllist1 and celllist2 parameters", + 400) + data = data.diffexp(cell_list_1, cell_list_2, pval, num_genes) + return make_payload(data) + + +def get_api_resources(): + bp = Blueprint("api", __name__, url_prefix="/api/v0.1") + api = Api(bp, add_api_spec_resource=False) + api.add_resource(InitializeAPI, "/initialize") + api.add_resource(CellsAPI, "/cells") + api.add_resource(ExpressionAPI, "/expression") + api.add_resource(DifferentialExpressionAPI, "/diffexpression") + return api diff --git a/server/app/scanpy_engine/__init__.py b/server/app/scanpy_engine/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/scanpy_engine/scanpy_engine.py b/server/app/scanpy_engine/scanpy_engine.py new file mode 100644 index 00000000..142f87a7 --- /dev/null +++ b/server/app/scanpy_engine/scanpy_engine.py @@ -0,0 +1,197 @@ +import os + +import numpy as np +import scanpy.api as sc +from scipy import stats + +from ..util.schema_parse import parse_schema +from ..driver.driver import CXGDriver + + +class ScanpyEngine(CXGDriver): + + def __init__(self, data, schema=None, graph_method="umap", diffexp_method="ttest"): + self.data = self._load_data(data) + self.schema = self._load_or_infer_schema(data, schema) + self._set_cell_names() + self.cell_count = self.data.shape[0] + self.gene_count = self.data.shape[1] + self.graph_method = graph_method + self.diffexp_method = diffexp_method + + def _set_cell_names(self): + self.data.obs["cell_name"] = list(self.data.obs.index) + + @staticmethod + def _load_data(data): + return sc.read(os.path.join(data, "data.h5ad")) + + @staticmethod + def _load_or_infer_schema(data, schema): + data_schema = None + if not schema: + pass + else: + data_schema = parse_schema(os.path.join(data, schema)) + return data_schema + + def cells(self): + return list(self.data.obs.index) + + def genes(self): + return self.data.var.index.tolist() + + def filter_cells(self, filter): + """ + Filter cells from data and return a subset of the data + A filter is a dictionary where the key is a metadatata category + Value is dictionary + value_type: int, float, string + variable_type: continuous, categorical + query: filter value, for categorical [val1, val2], for continuous {min: x, max:y} + Filters are combined with the and operator + :param filter: + :return: filtered dataframe + """ + cell_idx = np.ones((self.cell_count,), dtype=bool) + for key, value in filter.items(): + if value["variable_type"] == "categorical": + key_idx = np.in1d(getattr(self.data.obs, key), value["query"]) + cell_idx = np.logical_and(cell_idx, key_idx) + else: + min_ = value["query"]["min"] + max_ = value["query"]["max"] + if min_: + key_idx = np.array((getattr(self.data.obs, key) >= min_).data) + cell_idx = np.logical_and(cell_idx, key_idx) + if max_: + key_idx = np.array((getattr(self.data.obs, key) <= min_).data) + cell_idx = np.logical_and(cell_idx, key_idx) + return self.data[cell_idx, :] + + def metadata_ranges(self, df=None): + metadata_ranges = {} + if not df: + df = self.data + for field in self.schema: + if self.schema[field]["variabletype"] == "categorical": + group_by = field + if group_by == "CellName": + group_by = "cell_name" + metadata_ranges[field] = {"options": df.obs.groupby(group_by).size().to_dict()} + else: + metadata_ranges[field] = { + "range": { + "min": df.obs[field].min(), + "max": df.obs[field].max() + } + } + return metadata_ranges + + def metadata(self, df, fields=None): + """ + Gets metadata key:value for each cells + + :param df: from filter_cells, dataframe + :param fields: list of keys for metadata to return, returns all metadata values if not set. + :return: list of metadata values + """ + metadata = df.obs.to_dict(orient="records") + for idx in range(len(metadata)): + metadata[idx]["CellName"] = metadata[idx].pop("cell_name", None) + return metadata + + def create_graph(self, df): + """ + Computes a n-d layout for cells through dimensionality reduction. + :param df: from filter_cells, dataframe + :return: [cellid, x, y] + """ + getattr(sc.tl, self.graph_method)(df) + graph = df.obsm["X_{graph_method}".format(graph_method=self.graph_method)] + normalized_graph = (graph - graph.min()) / (graph.max() - graph.min()) + return np.hstack((df.obs["cell_name"].values.reshape(len(df.obs.index), 1), normalized_graph)).tolist() + + def diffexp(self, cell_list_1, cell_list_2, pval, num_genes): + """ + Computes the top differentially expressed genes between two clusters + :param df1: from filter_cells, dataframe containing first set of cells + :param df2: from filter_cells, dataframe containing second set of cells + :return: top genes, stats and expression values for top genes + """ + cells_idx_1 = np.in1d(self.data.obs["cell_name"], cell_list_1) + cells_idx_2 = np.in1d(self.data.obs["cell_name"], cell_list_2) + expression_1 = self.data.X[cells_idx_1, :] + expression_2 = self.data.X[cells_idx_2, :] + diff_exp = stats.ttest_ind(expression_1, expression_2) + set1 = np.logical_and(diff_exp.pvalue < pval, diff_exp.statistic > 0) + set2 = np.logical_and(diff_exp.pvalue < pval, diff_exp.statistic < 0) + stat1 = diff_exp.statistic[set1] + stat2 = diff_exp.statistic[set2] + sort_set1 = np.argsort(stat1)[::-1] + sort_set2 = np.argsort(stat2) + pval1 = diff_exp.pvalue[set1][sort_set1] + pval2 = diff_exp.pvalue[set2][sort_set2] + mean_ex1_set1 = np.mean(expression_1[:, set1], axis=0)[sort_set1] + mean_ex2_set1 = np.mean(expression_2[:, set1], axis=0)[sort_set1] + mean_ex1_set2 = np.mean(expression_1[:, set2], axis=0)[sort_set2] + mean_ex2_set2 = np.mean(expression_2[:, set2], axis=0)[sort_set2] + mean_diff1 = mean_ex1_set1 - mean_ex2_set1 + mean_diff2 = mean_ex1_set2 - mean_ex2_set2 + genes_cellset_1 = self.data.var_names[set1][sort_set1] + genes_cellset_2 = self.data.var_names[set2][sort_set2] + return { + "celllist1": { + "topgenes": genes_cellset_1.tolist()[:num_genes], + "mean_expression_cellset1": mean_ex1_set1.tolist()[:num_genes], + "mean_expression_cellset2": mean_ex2_set1.tolist()[:num_genes], + "pval": pval1.tolist()[:num_genes], + "ave_diff": mean_diff1.tolist()[:num_genes] + }, + "celllist2": { + "topgenes": genes_cellset_2.tolist()[:num_genes], + "mean_expression_cellset1": mean_ex1_set2.tolist()[:num_genes], + "mean_expression_cellset2": mean_ex2_set2.tolist()[:num_genes], + "pval": pval2.tolist()[:num_genes], + "ave_diff": mean_diff2.tolist()[:num_genes] + }, + } + + def expression(self, cells=None, genes=None): + """ + Retrieves expression for each gene for cells in data frame + :param df: + :return: { + "genes": list of genes, + "cells": list of cells and expression list, + "nonzero_gene_count": number of nonzero genes + } + """ + if cells: + cells_idx = np.in1d(self.data.obs["cell_name"], cells) + else: + cells_idx = np.ones((self.cell_count,), dtype=bool) + if genes: + genes_idx = np.in1d(self.data.var_names, genes) + else: + genes_idx = np.ones((self.gene_count,), dtype=bool) + index = np.ix_(cells_idx, genes_idx) + expression = self.data.X[index] + + if not genes: + genes = self.data.var.index.tolist() + if not cells: + cells = self.data.obs["cell_name"].tolist() + + cell_data = [] + for idx, cell in enumerate(cells): + cell_data.append({ + "cellname": cell, + "e": list(expression[idx]), + }) + + return { + "genes": genes, + "cells": cell_data, + "nonzero_gene_count": int(np.sum(expression.any(axis=0))) + } diff --git a/server/app/util/__init__.py b/server/app/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/util/filter.py b/server/app/util/filter.py new file mode 100644 index 00000000..51b7f8c5 --- /dev/null +++ b/server/app/util/filter.py @@ -0,0 +1,70 @@ +class QueryStringError(Exception): + pass + + +def _convert_variable(datatype, variable): + """ + Convert variable to number (float/int) + Used for dataset metadata and for query string + :param datatype: type to convert to + :param variable (string or None): value of variable + :return: converted variable + :raises: ValueError + """ + try: + if variable is None: + return variable + if datatype == "int": + variable = int(variable) + elif datatype == "float": + variable = float(variable) + return variable + except ValueError: + raise + + +def parse_filter(filter, schema): + """ + The filter comes in as arguments from a GET/POST request + For categorical metadata keys filter based on key=value + For continuous metadata keys filter by key=min,max + Either value can be replaced by a * To have only a minimum value key=min, To have only a maximum value key=*,max + + They combine via AND so a cell's metadata would have to match every filter + + The results is a matrix with the cells the pass the filter and at this point all the genes + :param filter: flask's request.args + :param schema: dictionary schema + :return: + """ + query = {} + for key in filter: + value = filter.getlist(key) + if key not in schema: + raise QueryStringError("Error: key {} not in metadata schema".format(key)) + query[key] = { + "variable_type": schema[key]["variabletype"], + "value_type": schema[key]["type"] + } + if query[key]["variable_type"] == "categorical": + query[key]["query"] = [_convert_variable(query[key]["value_type"], v) for v in value] + elif query[key]["variable_type"] == "continuous": + value = value[0] + try: + min, max = value.split(",") + except ValueError: + raise QueryStringError("Error: min,max format required for range for key {}, got {}".format(key, value)) + if min == "*": + min = None + if max == "*": + max = None + try: + query[key]["query"] = { + "min": _convert_variable(query[key]["value_type"], min), + "max": _convert_variable(query[key]["value_type"], max) + } + except ValueError: + raise QueryStringError( + "Error: expected type {} for key {}, got {}".format(query[key]["type"], key, value) + ) + return query diff --git a/server/app/util/schema_parse.py b/server/app/util/schema_parse.py new file mode 100644 index 00000000..48665e40 --- /dev/null +++ b/server/app/util/schema_parse.py @@ -0,0 +1,7 @@ +import json + + +def parse_schema(filename): + with open(filename) as fh: + schema = json.load(fh) + return schema diff --git a/server/app/util/utils.py b/server/app/util/utils.py new file mode 100644 index 00000000..0ef8f98c --- /dev/null +++ b/server/app/util/utils.py @@ -0,0 +1,40 @@ +import json + +from numpy import float32, integer +from flask import make_response, jsonify, Response + + +class Float32JSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, float32): + return float(obj) + elif isinstance(obj, integer): + return int(obj) + return json.JSONEncoder.default(self, obj) + + +def make_payload(data, errormessage="", errorcode=200): + """ + Creates JSON respons for requests + :param data: json data + :param errormessage: error message + :param errorcode: http error code + :return: flask json repsonse + """ + error = False + if errormessage: + error = True + # Questionable + data = json.loads(json.dumps(data, cls=Float32JSONEncoder)) + return make_response(jsonify({ + "data": data, + "status": { + "error": error, + "errormessage": errormessage, + } + }), errorcode) + + +def make_streaming_response(data_generator, errorcode=200, content_type="application/json"): + # TODO headers + return Response(data_generator, status=errorcode, content_type=content_type) diff --git a/server/app/web/__init__.py b/server/app/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/web/templates/swagger.html b/server/app/web/templates/swagger.html new file mode 100644 index 00000000..e404fec4 --- /dev/null +++ b/server/app/web/templates/swagger.html @@ -0,0 +1,97 @@ + + + + + CellxGene REST API - Swagger definition + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + diff --git a/server/app/web/webapp.py b/server/app/web/webapp.py new file mode 100644 index 00000000..4717f155 --- /dev/null +++ b/server/app/web/webapp.py @@ -0,0 +1,24 @@ +from flask import ( + Blueprint, render_template, url_for, current_app +) + +bp = Blueprint("webapp", __name__, template_folder="templates") + + +@bp.route("/") +def index(): + url_base = current_app.config["CXG_API_BASE"] + dataset_title = current_app.config["DATASET_TITLE"] + return render_template("index.html", prefix=url_base, datasetTitle=dataset_title) + + +# renders swagger documentation +@bp.route("/swagger") +def swag(): + return render_template("swagger.html") + + +# renders swagger documentation +@bp.route("/favicon.png") +def favicon(): + return url_for("static", filename="img/favicon.png") diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 00000000..cdd76273 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,41 @@ +aniso8601==3.0.2 +anndata==0.6.1 +certifi==2018.4.16 +chardet==3.0.4 +click==6.7 +cycler==0.10.0 +decorator==4.3.0 +Flask==0.12.4 +Flask-Compress==1.4.0 +Flask-Cors==3.0.6 +Flask-RESTful==0.3.6 +flask-restful-swagger-2==0.35 +h5py==2.8.0 +idna==2.7 +itsdangerous==0.24 +Jinja2==2.10 +joblib==0.12.0 +kiwisolver==1.0.1 +llvmlite==0.23.2 +MarkupSafe==1.0 +matplotlib==2.2.2 +natsort==5.3.2 +networkx==2.1 +numba==0.38.1 +numexpr==2.6.5 +numpy==1.14.5 +pandas==0.23.1 +patsy==0.5.0 +pyparsing==2.2.0 +python-dateutil==2.7.3 +pytz==2018.4 +requests==2.19.1 +scanpy==1.2.2 +scikit-learn==0.19.1 +scipy==1.1.0 +seaborn==0.8.1 +six==1.11.0 +statsmodels==0.9.0 +tables==3.4.4 +urllib3==1.23 +Werkzeug==0.14.1 diff --git a/server/run.py b/server/run.py new file mode 100644 index 00000000..c7afec43 --- /dev/null +++ b/server/run.py @@ -0,0 +1,3 @@ +from app import app + +app.run(host="0.0.0.0", debug=True, port=5005) diff --git a/server/test/test_api.py b/server/test/test_api.py new file mode 100644 index 00000000..b004740a --- /dev/null +++ b/server/test/test_api.py @@ -0,0 +1,54 @@ +import unittest +import requests +import json + + +class EndPoints(unittest.TestCase): + """Test Case for endpoints""" + + def setUp(self): + # Local + self.url_base = "http://0.0.0.0:5005/api/" + "v0.1/" + self.session = requests.Session() + + def test_cells(self): + url = "{base}{endpoint}?{params}".format(base=self.url_base, endpoint="cells", params="&".join( + ["louvain=B cells"])) + result = self.session.get(url) + assert result.status_code == 200 + result_data = result.json() + assert "B cells" in result_data["data"]["ranges"]["louvain"]["options"] + url = "{base}{endpoint}?{params}".format(base=self.url_base, endpoint="cells", params="&".join( + ["louvain=B cells", "louvain=Megakaryocytes"])) + result = self.session.get(url) + assert result.status_code == 200 + result_data = result.json() + assert "Megakaryocytes" in result_data["data"]["ranges"]["louvain"]["options"] + + def test_initialize(self): + url = "{base}{endpoint}".format(base=self.url_base, endpoint="initialize") + result = self.session.get(url) + assert result.status_code == 200 + result_data = result.json() + assert result_data["data"]["cellcount"] == 2638 + assert len(result_data["data"]['ranges']['CellName']['options']) == 2638 + + + def test_expression_get(self): + url = "{base}{endpoint}".format(base=self.url_base, endpoint="expression") + result = self.session.get(url) + assert result.status_code == 200 + + def test_expression_post(self): + url = "{base}{endpoint}".format(base=self.url_base, endpoint="expression") + result = self.session.post(url, data=json.dumps({"celllist": ["AAACATACAACCAC-1", "AACCGATGGTCATG-1"], "genelist": ["BACH1", "MIS18A", "ATP5O"]}), headers={'content-type': 'application/json'}) + assert result.status_code == 200 + result_data = result.json() + assert len(result_data["data"]["cells"]) == 2 + assert len(result_data["data"]["cells"][0]['e']) == 3 + + def test_diffexp(self): + url = "{base}{endpoint}".format(base=self.url_base, endpoint="diffexpression") + result = self.session.post(url, data=json.dumps({"celllist1": ["AAACATACAACCAC-1", "AACCGATGGTCATG-1"], "celllist2": ["CCGATAGACCTAAG-1", "GGTGGAGAAGTAGA-1"]}), headers={'content-type': 'application/json'}) + assert result.status_code == 200 + \ No newline at end of file diff --git a/server/test/test_filter.py b/server/test/test_filter.py new file mode 100644 index 00000000..f46cd14a --- /dev/null +++ b/server/test/test_filter.py @@ -0,0 +1,74 @@ +import unittest + +from unittest.mock import MagicMock +import sys +sys.path.insert(0, "../app") +from util.filter import _convert_variable, parse_filter + + +class UtilTest(unittest.TestCase): + """Test Case for endpoints""" + + def setUp(self): + self.schema = { + "cluster": { + "displayname": "Cluster", + "include": True, + "type": "int", + "variabletype": "categorical" + }, + "louvain": { + "displayname": "Louvain Cluster", + "include": True, + "type": "string", + "variabletype": "categorical" + }, + "n_genes": { + "displayname": "Num Genes", + "include": True, + "type": "int", + "variabletype": "continuous" + } + } + + def test_convert(self): + five = _convert_variable("int", "5") + assert five == 5 + + def test_convert_zero(self): + zero = _convert_variable("int", "0") + assert zero == 0 + + def test_empty_convert(self): + empty = _convert_variable("int", None) + assert empty is None + + def test_bad_convert(self): + with self.assertRaises(ValueError): + _convert_variable("int", "5.5") + + def test_filter_categorical(self): + filterMock = MagicMock() + filterMock.__iter__.return_value = iter(["louvain"]) + filterMock.getlist.return_value = ["B cells", "T cells"] + query = parse_filter(filterMock, self.schema) + assert query == {"louvain": {"variable_type": "categorical", "value_type": "string", "query": ["B cells", "T cells"]}} + filterMock.__iter__.return_value = iter(["cluster"]) + filterMock.getlist.return_value = ["1", "2"] + query = parse_filter(filterMock, self.schema) + assert query == {"cluster": {"variable_type": "categorical", "value_type": "int", "query": [1, 2]}} + + def test_filter_contiunous(self): + filterMock = MagicMock() + filterMock.__iter__.return_value = iter(["n_genes"]) + filterMock.getlist.return_value = ["0,100"] + query = parse_filter(filterMock, self.schema) + assert query == {"n_genes": {"variable_type": "continuous", "value_type": "int", "query": {"min": 0, "max": 100}}} + filterMock.__iter__.return_value = iter(["n_genes"]) + filterMock.getlist.return_value = ["*,100"] + query = parse_filter(filterMock, self.schema) + assert query == {"n_genes": {"variable_type": "continuous", "value_type": "int", "query": {"min": None, "max": 100}}} + filterMock.__iter__.return_value = iter(["n_genes"]) + filterMock.getlist.return_value = ["0,*"] + query = parse_filter(filterMock, self.schema) + assert query == {"n_genes": {"variable_type": "continuous", "value_type": "int", "query": {"min": 0, "max": None}}}