diff --git a/js/widget.js b/js/widget.js index 2d399bed..561cecc4 100644 --- a/js/widget.js +++ b/js/widget.js @@ -1,5 +1,6 @@ import './widget.css'; +import { options, set_options } from './global_variables/fetch_options'; import { networkFromParquet } from './read_parquet/network_from_parquet'; import { objects_from_parquet } from './read_parquet/objects_from_parquet'; import { @@ -12,6 +13,69 @@ import { landscape_sst } from './viz/landscape_sst'; import { matrix_viz } from './viz/matrix_viz'; import { render_enrich } from './widgets/enrich_widget'; +function issueCrossPlatformWarning(message, model, el, showInNotebook = true) { + /* eslint-disable-next-line no-console */ + console.warn(`⚠️ ${message}`); + + if (showInNotebook) { + const warnDiv = document.createElement('div'); + warnDiv.style.color = 'orange'; + warnDiv.style.padding = '6px'; + warnDiv.style.fontSize = '0.9em'; + warnDiv.style.fontWeight = 'bold'; + warnDiv.textContent = `⚠️ ${message}`; + el.appendChild(warnDiv); + } + + if (model?.send) { + model.send({ event: 'js_warning', message }); + } +} + +const fetchLandscapeTechnology = async (model, _el) => { + const base_url = model.get('base_url'); + const token = model.get('token'); + + try { + set_options(token); + const url = `${base_url}/landscape_parameters.json`; + const response = await fetch(url, options.fetch); + + if (!response.ok) { + const error = new Error( + `Failed to fetch landscape_parameters.json: ${response.statusText}` + ); + error.status = response.status; + throw error; + } + + const json = await response.json(); + + if (!json.technology) { + const message = + 'The landscape_parameters.json file appears to be missing the `technology` field. Please verify its contents.'; + + /* eslint-disable-next-line no-console */ + console.warn(`⚠️ ${message}`); + model.send({ event: 'js_error', message }); + throw new Error(message); + } + + return json.technology; + } catch (error) { + const errorResult = handleAsyncError(error, { + context: 'fetchLandscapeTechnology', + messages: { + notFound: 'landscape_parameters.json not found', + unexpected: 'Error fetching landscape_parameters.json', + }, + }); + + model.send({ event: 'js_error', message: errorResult.message }); + return null; + } +}; + // Remove export keywords from render functions const render_landscape_ist = async ({ model, el }) => { const token = model.get('token'); @@ -136,14 +200,60 @@ const render_landscape_h_e = async ({ model, el }) => { ); }; +const DEFAULT_TECHNOLOGY = 'Xenium'; + const render_landscape = async ({ model, el }) => { - const technology = model.get('technology'); + let technology = model.get('technology'); + const userPassedTechnology = + Object.prototype.hasOwnProperty.call(model, 'attributes') && + Object.prototype.hasOwnProperty.call(model.attributes, 'technology'); + + if (!technology) { + issueCrossPlatformWarning( + 'Technology was not passed in the function – attempting to fetch this from landscape_parameters.json.', + model, + el, + false + ); + + const fetchedTech = await fetchLandscapeTechnology(model, el); - if (['MERSCOPE', 'Xenium', 'Chromium'].includes(technology)) { + if (!fetchedTech) { + // Fallback to DEFAULT_TECHNOLOGY with a strong warning + const fallbackMsg = + `Neither technology was explicitly passed nor found in landscape_parameters.json. ` + + `Falling back to default: ${DEFAULT_TECHNOLOGY}`; + issueCrossPlatformWarning(fallbackMsg, model, el); + + technology = DEFAULT_TECHNOLOGY; + } else { + technology = fetchedTech; + } + + model.set('technology', technology); + model.save_changes(); + } else if (userPassedTechnology) { + issueCrossPlatformWarning( + 'Setting `technology` manually is deprecated and will be removed in a future release. Please rely on automatic detection via landscape_parameters.json.', + model, + el + ); + } + + if ( + !['MERSCOPE', DEFAULT_TECHNOLOGY, 'Visium-HD', 'h&e'].includes(technology) + ) { + const msg = `Unsupported technology: ${technology}`; + handleValidationWarning(msg); + model.send({ event: 'js_warning', message: msg }); + return; + } + + if (['MERSCOPE', DEFAULT_TECHNOLOGY, 'Chromium'].includes(technology)) { return render_landscape_ist({ model, el }); - } else if (['Visium-HD'].includes(technology)) { + } else if (technology === 'Visium-HD') { return render_landscape_sst({ model, el }); - } else if (['h&e'].includes(technology)) { + } else if (technology === 'h&e') { return render_landscape_h_e({ model, el }); } }; @@ -172,6 +282,18 @@ const render_matrix_new = async ({ model, el }) => { // Main render function - no export keyword async function render({ model, el }) { let cleanup = null; + model.on('msg:custom', (msg) => { + if (msg.event === 'py_warning') { + /* eslint-disable-next-line no-console */ + console.warn('[PYTHON WARNING]', msg.message); + el.innerHTML += `
⚠️ ${msg.message}
`; + } else if (msg.event === 'py_error') { + /* eslint-disable-next-line no-console */ + console.error('[PYTHON ERROR]', msg.message); + el.innerHTML += `
❌ ${msg.message}
`; + } + }); + try { const componentType = model.get('component'); diff --git a/notebooks/Custom_Segmentation.ipynb b/notebooks/Custom_Segmentation.ipynb index 0822baec..4161db3a 100644 --- a/notebooks/Custom_Segmentation.ipynb +++ b/notebooks/Custom_Segmentation.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 5, + "execution_count": 1, "id": "9bd79809-7286-463c-a92a-e6315856d0a1", "metadata": {}, "outputs": [ @@ -10,8 +10,6 @@ "name": "stdout", "output_type": "stream", "text": [ - "The autoreload extension is already loaded. To reload it, use:\n", - " %reload_ext autoreload\n", "env: ANYWIDGET_HMR=1\n" ] } @@ -24,17 +22,17 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 2, "id": "236fbcbe-36de-483b-9afd-13a379a56899", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'0.4.0'" + "'0.9.0'" ] }, - "execution_count": 6, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -57,7 +55,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 3, "id": "46377171-5592-418f-a6a1-0658a8494954", "metadata": {}, "outputs": [], @@ -76,7 +74,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 5, "id": "cb0602d3-c572-417b-88e3-ba036098f050", "metadata": {}, "outputs": [ @@ -90,126 +88,48 @@ "Calculating mean expression\n", "Calculating variance\n", "All meta gene files are succesfully saved.\n", - "data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs/cbg_cellpose2\n", "\n", - "========Write gene-specific parquet files========\n", - "Processing gene 0: A2ML1\n", - "Processing gene 100: ADIPOR1\n", - "Processing gene 200: ANKRD40\n", - "Processing gene 300: ATF2\n", - "Processing gene 400: BDNF\n", - "Processing gene 500: CALCRL\n", - "Processing gene 600: CCL20\n", - "Processing gene 700: CD72\n", - "Processing gene 800: CFC1\n", - "Processing gene 900: CNKSR3\n", - "Processing gene 1000: CSNK1A1\n", - "Processing gene 1100: CYTH2\n", - "Processing gene 1200: DMKN\n", - "Processing gene 1300: DeprecatedCodeword_0994\n", - "Processing gene 1400: DeprecatedCodeword_15617\n", - "Processing gene 1500: DeprecatedCodeword_4666\n", - "Processing gene 1600: ECD\n", - "Processing gene 1700: EPCAM\n", - "Processing gene 1800: FANCC\n", - "Processing gene 1900: FMO3\n", - "Processing gene 2000: GALNS\n", - "Processing gene 2100: GPATCH11\n", - "Processing gene 2200: H3F3B\n", - "Processing gene 2300: HOXB9\n", - "Processing gene 2400: IFNW1\n", - "Processing gene 2500: INCA1\n", - "Processing gene 2600: JAM2\n", - "Processing gene 2700: KIR3DL1\n", - "Processing gene 2800: LILRA6\n", - "Processing gene 2900: MALL\n", - "Processing gene 3000: MEST\n", - "Processing gene 3100: MTCH2\n", - "Processing gene 3200: NCSTN\n", - "Processing gene 3300: NORAD\n", - "Processing gene 3400: NXPH2\n", - "Processing gene 3500: NegControlCodeword_18746\n", - "Processing gene 3600: NegControlCodeword_18846\n", - "Processing gene 3700: NegControlCodeword_18946\n", - "Processing gene 3800: NegControlCodeword_19046\n", - "Processing gene 3900: NegControlCodeword_19146\n", - "Processing gene 4000: NegControlCodeword_19246\n", - "Processing gene 4100: P2RX1\n", - "Processing gene 4200: PDE6H\n", - "Processing gene 4300: PKIA\n", - "Processing gene 4400: PPARD\n", - "Processing gene 4500: PRXL2A\n", - "Processing gene 4600: RABL2B\n", - "Processing gene 4700: RGN\n", - "Processing gene 4800: RUBCN\n", - "Processing gene 4900: SERPINA9\n", - "Processing gene 5000: SLC17A8\n", - "Processing gene 5100: SMC1A\n", - "Processing gene 5200: SPATS2L\n", - "Processing gene 5300: STX7\n", - "Processing gene 5400: TENT5B\n", - "Processing gene 5500: TMEM130\n", - "Processing gene 5600: TPX2\n", - "Processing gene 5700: TUFM\n", - "Processing gene 5800: UnassignedCodeword_0100\n", - "Processing gene 5900: UnassignedCodeword_0579\n", - "Processing gene 6000: UnassignedCodeword_0998\n", - "Processing gene 6100: UnassignedCodeword_10427\n", - "Processing gene 6200: UnassignedCodeword_10846\n", - "Processing gene 6300: UnassignedCodeword_11242\n", - "Processing gene 6400: UnassignedCodeword_11661\n", - "Processing gene 6500: UnassignedCodeword_12151\n", - "Processing gene 6600: UnassignedCodeword_12526\n", - "Processing gene 6700: UnassignedCodeword_12880\n", - "Processing gene 6800: UnassignedCodeword_13294\n", - "Processing gene 6900: UnassignedCodeword_13728\n", - "Processing gene 7000: UnassignedCodeword_14115\n", - "Processing gene 7100: UnassignedCodeword_14486\n", - "Processing gene 7200: UnassignedCodeword_1487\n", - "Processing gene 7300: UnassignedCodeword_15237\n", - "Processing gene 7400: UnassignedCodeword_15662\n", - "Processing gene 7500: UnassignedCodeword_16071\n", - "Processing gene 7600: UnassignedCodeword_16476\n", - "Processing gene 7700: UnassignedCodeword_16830\n", - "Processing gene 7800: UnassignedCodeword_17241\n", - "Processing gene 7900: UnassignedCodeword_17672\n", - "Processing gene 8000: UnassignedCodeword_18196\n", - "Processing gene 8100: UnassignedCodeword_1886\n", - "Processing gene 8200: UnassignedCodeword_2384\n", - "Processing gene 8300: UnassignedCodeword_2846\n", - "Processing gene 8400: UnassignedCodeword_3332\n", - "Processing gene 8500: UnassignedCodeword_3845\n", - "Processing gene 8600: UnassignedCodeword_4262\n", - "Processing gene 8700: UnassignedCodeword_4726\n", - "Processing gene 8800: UnassignedCodeword_5195\n", - "Processing gene 8900: UnassignedCodeword_5693\n", - "Processing gene 9000: UnassignedCodeword_6174\n", - "Processing gene 9100: UnassignedCodeword_6629\n", - "Processing gene 9200: UnassignedCodeword_7161\n", - "Processing gene 9300: UnassignedCodeword_7569\n", - "Processing gene 9400: UnassignedCodeword_8046\n", - "Processing gene 9500: UnassignedCodeword_8534\n", - "Processing gene 9600: UnassignedCodeword_9011\n", - "Processing gene 9700: UnassignedCodeword_9525\n", - "Processing gene 9800: UnassignedCodeword_9939\n", - "Processing gene 9900: XPO1\n", - "Processing gene 10000: ZNF687\n", - "All gene-specific parquet files are succesfully saved.\n", + "========Make meta cells in pixel space========\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/jishar/Documents/celldega/src/celldega/pre/__init__.py:656: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.\n", "\n", - "========Make meta cells in pixel space========\n", + " meta_cell[\"center_x\"] = meta_cell.centroid.x\n", + "/Users/jishar/Documents/celldega/src/celldega/pre/__init__.py:657: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.\n", + "\n", + " meta_cell[\"center_y\"] = meta_cell.centroid.y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "Done.\n", + "data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/cbg_proseg\n", + "Processing gene 0: AMY2A\n", + "Processing gene 100: PTGDS\n", + "Processing gene 200: EGFL7\n", + "Processing gene 300: ESR1\n", + "Processing gene 400: NegControlCodeword_0523\n", + "Processing gene 500: UnassignedCodeword_0459\n", + "All gene-specific parquet files are succesfully saved.\n", "\n", "========Create clusters and meta clusters files========\n", "Cell clusters and meta cluster files created successfully.\n", "\n", - "========Create cell boundary spatial tiles========\n" + "========Create cell boundary spatial tiles========\n", + "custom technology\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ - "Processing coarse tiles: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:15<00:00, 1.77s/it]\n" + "Processing coarse tiles: 100%|███████████████████████████████████████████████████████████████████████████████████| 7/7 [00:24<00:00, 3.56s/it]\n" ] }, { @@ -227,8 +147,8 @@ } ], "source": [ - "path_landscape_files=\"data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs\"\n", - "path_segmentation_files=\"data/processed_data/xenium_skin/cellpose2/\"\n", + "path_landscape_files=\"data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/\"\n", + "path_segmentation_files=\"data/processed_data/xenium_pancreas/proseg/\"\n", "\n", "dega.pre.add_custom_segmentation(path_landscape_files=path_landscape_files, \n", " path_segmentation_files=path_segmentation_files)" @@ -244,29 +164,22 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 7, "id": "7d440da2-1354-4653-ab09-bc24400f8833", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Server running on port 55358\n" - ] - }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "767f649f38764b20815f31721dc9be66", + "model_id": "60e4ec5c3c0e402f86228e2b4a6cf8a9", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "Landscape(base_url='http://localhost:55358/data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs', seg…" + "Landscape(base_url='http://localhost:52696/data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/', cel…" ] }, - "execution_count": 9, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -275,9 +188,8 @@ "server_address = dega.viz.get_local_server()\n", "\n", "landscape_ist = dega.viz.Landscape(\n", - " technology='Xenium',\n", " base_url = f\"http://localhost:{server_address}/{path_landscape_files}\",\n", - " segmentation='cellpose2'\n", + " segmentation='proseg'\n", ")\n", "\n", "landscape_ist" @@ -308,7 +220,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.20" + "version": "3.11.5" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/notebooks/Xenium_pre-process.ipynb b/notebooks/Xenium_pre-process.ipynb index 19a872b2..f7b9d86b 100644 --- a/notebooks/Xenium_pre-process.ipynb +++ b/notebooks/Xenium_pre-process.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 3, "id": "b81ab32e", "metadata": {}, "outputs": [ @@ -18,16 +18,10 @@ "name": "stdout", "output_type": "stream", "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n", "env: ANYWIDGET_HMR=1\n" ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/feni/Documents/celldega/dega/lib/python3.12/site-packages/h5py/__init__.py:36: UserWarning: h5py is running against HDF5 1.14.5 when it was built against 1.14.6, this may cause problems\n", - " _warn((\"h5py is running against HDF5 {0} when it was built against {1}, \"\n" - ] } ], "source": [ @@ -215,7 +209,6 @@ ], "source": [ "landscape_ist = dega.viz.Landscape(\n", - " technology='Xenium',\n", " base_url = f\"http://localhost:{dega.viz.get_local_server()}/{path_landscape_files}\",\n", ")\n", "\n", @@ -247,7 +240,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.7" + "version": "3.11.5" }, "toc": { "base_numbering": 1, diff --git a/pyproject.toml b/pyproject.toml index 220902b4..8efe16a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "hatchling.build" name = "celldega" version = "0.13.0" readme = "README.md" +requires-python = ">=3.8" dependencies = [ "anndata~=0.11.0", "anywidget~=0.9.18", diff --git a/src/celldega/pre/__init__.py b/src/celldega/pre/__init__.py index d72ad2f3..dd8323ee 100644 --- a/src/celldega/pre/__init__.py +++ b/src/celldega/pre/__init__.py @@ -664,6 +664,75 @@ def _load_meta_cell_by_technology(technology, path_meta_cell_micron): return meta_cell +def _make_names_unique(index: pd.Index) -> pd.Index: + """ + Ensure uniqueness of values in a pandas Index by appending suffixes to duplicates. + + For each duplicated entry, appends a hyphen and a count (e.g., 'name', 'name-1', 'name-2', ...). + The first occurrence of each name is left unchanged. + + Parameters: + index (pd.Index): A pandas Index potentially containing duplicate values. + + Returns: + pd.Index: A new Index with all values made unique. + """ + seen = {} + unique_names = [] + for name in index: + if name not in seen: + seen[name] = 0 + unique_names.append(name) + else: + seen[name] += 1 + unique_names.append(f"{name}-{seen[name]}") + return pd.Index(unique_names) + + +def _align_and_deduplicate_genes( + cbg_custom: pd.DataFrame, path_landscape_files: str +) -> pd.DataFrame: + """ + Ensures genes in cbg_custom and meta_gene are identical, + and makes duplicate gene names unique by suffixing. + Raises an error if there is a mismatch in gene sets. + + Parameters: + ----------- + cbg_custom : pd.DataFrame + DataFrame containing custom cell-by-gene matrix. + path_landscape_files : str + Path to directory containing meta_gene.parquet. + + Returns: + -------- + pd.DataFrame + cbg_custom with deduplicated gene names (columns). + """ + meta_gene_path = Path(path_landscape_files) / "meta_gene.parquet" + meta_gene = pd.read_parquet(meta_gene_path) + + # Compare unordered gene sets before deduplication + genes_meta = set(meta_gene.index) + genes_cbg = set(cbg_custom.columns) + + if genes_meta != genes_cbg: + missing_in_cbg = genes_meta - genes_cbg + missing_in_meta = genes_cbg - genes_meta + raise ValueError( + f"Mismatch between cbg_custom and meta_gene genes.\n" + f"Missing in cbg_custom (up to 20): {list(missing_in_cbg)[:20]}\n" + f"Missing in meta_gene (up to 20): {list(missing_in_meta)[:20]}" + ) + + # Make gene names unique consistently across both + cbg_custom.columns = _make_names_unique(pd.Index(cbg_custom.columns)) + meta_gene.index = _make_names_unique(pd.Index(meta_gene.index)) + + # Align column order to meta_gene index order + return cbg_custom.loc[:, meta_gene.index] + + def make_meta_cell_image_coord( technology, path_transformation_matrix, @@ -826,6 +895,8 @@ def make_chromium_from_anndata(adata, path_landscape_files): save_landscape_parameters( technology="Chromium", path_landscape_files=path_landscape_files, + image_width=100, + image_height=100, image_name="", tile_size=1, image_info=[], @@ -851,6 +922,8 @@ def get_max_zoom_level(path_image_pyramid): def save_landscape_parameters( technology, path_landscape_files, + image_width, + image_height, image_name="dapi_files", tile_size=1000, image_info=None, @@ -895,6 +968,7 @@ def save_landscape_parameters( "tile_size": "N.A.", "image_info": image_info, "image_format": image_format, + "image_dimensions": {"width": image_width, "height": image_height}, "use_int_index": "N.A.", } elif technology != "custom": @@ -905,6 +979,7 @@ def save_landscape_parameters( "tile_size": tile_size, "image_info": image_info, "image_format": image_format, + "image_dimensions": {"width": image_width, "height": image_height}, "use_int_index": use_int_index, } else: @@ -935,11 +1010,7 @@ def add_custom_segmentation( cbg_custom = pd.read_parquet(Path(path_segmentation_files) / "cell_by_gene_matrix.parquet") - # make sure all genes are present in cbg_custom - meta_gene = pd.read_parquet(Path(path_landscape_files) / "meta_gene.parquet") - missing_cols = meta_gene.index.difference(cbg_custom.columns) - for col in missing_cols: - cbg_custom[col] = 0 + cbg_custom = _align_and_deduplicate_genes(cbg_custom, path_landscape_files) make_meta_gene( cbg=cbg_custom, @@ -1010,6 +1081,8 @@ def add_custom_segmentation( save_landscape_parameters( technology=segmentation_parameters["technology"], path_landscape_files=path_landscape_files, + image_width=width, + image_height=height, image_name="dapi_files", tile_size=tile_size, image_format=".webp", diff --git a/src/celldega/pre/run_pre_processing.py b/src/celldega/pre/run_pre_processing.py index 3a565851..bf193fad 100644 --- a/src/celldega/pre/run_pre_processing.py +++ b/src/celldega/pre/run_pre_processing.py @@ -196,6 +196,9 @@ def main( # Make meta gene files dega.pre.make_meta_gene(cbg, str(paths["meta_gene"])) + # Check if the genes are unique before saving the cbg files + cbg = dega.pre._align_and_deduplicate_genes(cbg, path_landscape_files) + # Save CBG gene parquet files dega.pre.save_cbg_gene_parquets(path_landscape_files, cbg, verbose=True) @@ -248,7 +251,9 @@ def main( dega.pre.save_landscape_parameters( technology, path_landscape_files, - "dapi_files", + image_width=tile_bounds["x_max"], + image_height=tile_bounds["y_max"], + image_name="dapi_files", tile_size=tile_size, image_info=dega.pre.get_image_info(technology, image_tile_layer), image_format=".webp", diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py index 80263b27..8b0e03b2 100644 --- a/src/celldega/viz/widget.py +++ b/src/celldega/viz/widget.py @@ -69,7 +69,7 @@ class Landscape(anywidget.AnyWidget): _css = Path(__file__).parent / "../static" / "widget.css" component = traitlets.Unicode("Landscape").tag(sync=True) - technology = traitlets.Unicode("sst").tag(sync=True) + technology = traitlets.Unicode("").tag(sync=True) base_url = traitlets.Unicode("").tag(sync=True) token = traitlets.Unicode("").tag(sync=True) creds = traitlets.Dict({}).tag(sync=True) @@ -104,6 +104,14 @@ class Landscape(anywidget.AnyWidget): height = traitlets.Int(800).tag(sync=True) def __init__(self, **kwargs): + technology_value = kwargs.get("technology", "") + if technology_value: + warnings.warn( + "[DEPRECATION WARNING] Passing `technology` manually to the Landscape widget is deprecated and will be removed in a future release. " + "Please rely on automatic detection via `landscape_parameters.json`.", + stacklevel=2, + ) + adata = kwargs.pop("adata", None) or kwargs.pop("AnnData", None) pq_meta_cell = kwargs.pop("meta_cell_parquet", None) pq_meta_cluster = kwargs.pop("meta_cluster_parquet", None) @@ -232,6 +240,9 @@ def _df_to_bytes(df): super().__init__(**kwargs) + # handle messages from the frontend for warnings/errors + self.on_msg(self._handle_frontend_message) + # store DataFrames locally without syncing to the frontend self.meta_cell = meta_cell_df self.meta_nbhd = meta_nbhd_df @@ -262,6 +273,19 @@ def _df_to_bytes(df): elif self.nbhd_edit: self.nbhd_geojson = {"type": "FeatureCollection", "features": []} + def _handle_frontend_message(self, _, content, buffers=None): + event = content.get("event") + message = content.get("message", "") + + if event == "js_warning": + print(f"JavaScript warning: {message}") + warnings.warn(message, stacklevel=2) + elif event == "js_error": + print(f"JavaScript error: {message}") + warnings.warn(f"JavaScript error: {message}", stacklevel=2) + else: + print(f"Unhandled frontend event: {event}") + # @traitlets.observe("nbhd") # def _on_nbhd_change(self, change): # new = change["new"] diff --git a/tests/unit/test_enrich/test_enrich_widget.py b/tests/unit/test_enrich/test_enrich_widget.py index 2e74a113..36ff331f 100644 --- a/tests/unit/test_enrich/test_enrich_widget.py +++ b/tests/unit/test_enrich/test_enrich_widget.py @@ -34,4 +34,3 @@ def test_enrich_traitlets_update() -> None: assert w.top_n_genes == 20 w.background_list = ["X", "Y"] assert w.background_list == ["X", "Y"] - diff --git a/tests/unit/test_pre/test_pre_tiles.py b/tests/unit/test_pre/test_pre_tiles.py index 36af7bb6..7778fa92 100644 --- a/tests/unit/test_pre/test_pre_tiles.py +++ b/tests/unit/test_pre/test_pre_tiles.py @@ -1,13 +1,14 @@ import importlib.util import math +from pathlib import Path import sys import types -from pathlib import Path import numpy as np import pandas as pd import pytest + try: import geopandas as gpd import polars as pl @@ -33,9 +34,7 @@ sys.modules["celldega.pre.boundary_tile"] = boundary_tile spec_b.loader.exec_module(boundary_tile) -spec_t = importlib.util.spec_from_file_location( - "celldega.pre.trx_tile", PRE_ROOT / "trx_tile.py" -) +spec_t = importlib.util.spec_from_file_location("celldega.pre.trx_tile", PRE_ROOT / "trx_tile.py") trx_tile = importlib.util.module_from_spec(spec_t) trx_tile.__package__ = "celldega.pre" sys.modules["celldega.pre.trx_tile"] = trx_tile @@ -50,6 +49,7 @@ TILE_SIZE = 250 BBOX = (0, 500, 0, 500) + def create_cell_polygon(df: pd.DataFrame) -> Polygon: """ Constructs a Shapely Polygon from a DataFrame containing 'vertex_x' and 'vertex_y' columns. @@ -76,16 +76,18 @@ def create_cell_polygon(df: pd.DataFrame) -> Polygon: if len(df) < 3: raise ValueError("At least three vertices are required to construct a polygon.") - return Polygon(zip(df["vertex_x"], df["vertex_y"])) + return Polygon(zip(df["vertex_x"], df["vertex_y"], strict=False)) + @pytest.fixture def make_synthetic_data(tmp_path): def _make(technology): return _generate_synthetic_data(tmp_path, technology) + return _make -def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]: +def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]: """ Generate synthetic spatial transcriptomics data for testing purposes. @@ -182,18 +184,15 @@ def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path] df_meta_cell = pd.DataFrame({"name": [f"cell_{i}" for i in range(N_CELLS)]}) df_meta_cell.to_parquet(tmp_path / "cell_metadata.parquet", index=False) - points = [ - (rng.uniform(BBOX[0], BBOX[1]), rng.uniform(BBOX[2], BBOX[3])) - for _ in range(N_TRX) - ] - genes = [f"G{i%3}" for i in range(N_TRX)] + points = [(rng.uniform(BBOX[0], BBOX[1]), rng.uniform(BBOX[2], BBOX[3])) for _ in range(N_TRX)] + genes = [f"G{i % 3}" for i in range(N_TRX)] if technology == "MERSCOPE": df_trx = pl.DataFrame( { "gene": genes, "global_x": [p[0] for p in points], "global_y": [p[1] for p in points], - "cell_id": [f"cell_{i%N_CELLS}" for i in range(N_TRX)], + "cell_id": [f"cell_{i % N_CELLS}" for i in range(N_TRX)], "transcript_id": list(range(N_TRX)), } ) @@ -203,7 +202,7 @@ def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path] "feature_name": genes, "x_location": [p[0] for p in points], "y_location": [p[1] for p in points], - "cell_id": [f"cell_{i%N_CELLS}" for i in range(N_TRX)], + "cell_id": [f"cell_{i % N_CELLS}" for i in range(N_TRX)], "transcript_id": list(range(N_TRX)), } ) @@ -290,9 +289,7 @@ def test_tiles(make_synthetic_data, technology) -> None: assert total_trx == N_TRX # Step 3: Ensure that every transcript maps to one of the generated transcript tile coordinates - produced_trx_tiles = { - tuple(map(int, p.stem.split("_")[-2:])) for p in trx_tile_files - } + produced_trx_tiles = {tuple(map(int, p.stem.split("_")[-2:])) for p in trx_tile_files} if technology == "MERSCOPE": df_trx = pl.read_parquet(paths["trx_path"]).to_pandas() @@ -303,7 +300,7 @@ def test_tiles(make_synthetic_data, technology) -> None: else: raise ValueError(f"Unsupported technology: {technology}") - for x, y in zip(df_trx[xcol], df_trx[ycol]): + for x, y in zip(df_trx[xcol], df_trx[ycol], strict=False): i = int((x - bounds["x_min"]) // TILE_SIZE) j = int((y - bounds["y_min"]) // TILE_SIZE) assert (i, j) in produced_trx_tiles @@ -348,10 +345,7 @@ def test_tiles(make_synthetic_data, technology) -> None: polygons = df_cells["Geometry"] elif technology == "Xenium": df_cells = pd.read_parquet(paths["boundaries_path"]) - polygons = ( - df_cells.groupby("cell_id")[["vertex_x", "vertex_y"]] - .apply(create_cell_polygon) - ) + polygons = df_cells.groupby("cell_id")[["vertex_x", "vertex_y"]].apply(create_cell_polygon) else: raise ValueError(f"Unsupported technology: {technology}") @@ -363,15 +357,10 @@ def test_tiles(make_synthetic_data, technology) -> None: assert len(all_cells) >= expected_cells # Verify that each expected cell polygon maps to a cell tile by centroid location - produced_cell_tiles = { - tuple(map(int, p.stem.split("_")[-2:])) for p in cell_tile_files - } + produced_cell_tiles = {tuple(map(int, p.stem.split("_")[-2:])) for p in cell_tile_files} for poly in polygons: - if not ( - BBOX[0] <= poly.centroid.x < BBOX[1] - and BBOX[2] <= poly.centroid.y < BBOX[3] - ): + if not (BBOX[0] <= poly.centroid.x < BBOX[1] and BBOX[2] <= poly.centroid.y < BBOX[3]): continue i = int((poly.centroid.x - bounds["x_min"]) // TILE_SIZE) j = int((poly.centroid.y - bounds["y_min"]) // TILE_SIZE) - assert (i, j) in produced_cell_tiles \ No newline at end of file + assert (i, j) in produced_cell_tiles diff --git a/tests/unit/test_viz/test_landscape_colors.py b/tests/unit/test_viz/test_landscape_colors.py index 27e053ca..988ff56e 100644 --- a/tests/unit/test_viz/test_landscape_colors.py +++ b/tests/unit/test_viz/test_landscape_colors.py @@ -1,24 +1,32 @@ +import io +import json +from unittest.mock import patch + import numpy as np import pandas as pd import pytest + try: import anndata as ad + from celldega.viz import Landscape except Exception as e: # pragma: no cover - if deps missing skip pytest.skip(f"celldega modules unavailable: {e}", allow_module_level=True) -def test_leiden_colors_added_if_missing() -> None: +def mock_urlopen_success(*args, **kwargs): + fake_json = json.dumps({"technology": "Xenium"}).encode("utf-8") + return io.BytesIO(fake_json) + + +@patch("celldega.viz.widget.urllib.request.urlopen", side_effect=mock_urlopen_success) +def test_leiden_colors_added_if_missing(mock_urlopen) -> None: adata = ad.AnnData(np.zeros((3, 3))) adata.obs["leiden"] = pd.Categorical(["0", "1", "0"]) adata.uns.pop("leiden_colors", None) - adata.obsm["X_umap"] = np.array([ - [0.0, 0.0], - [1.0, 1.0], - [2.0, 2.0] - ]) + adata.obsm["X_umap"] = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]]) widget = Landscape(adata=adata) diff --git a/tests/unit/test_viz/test_widget.py b/tests/unit/test_viz/test_widget.py index 871ab094..e331041c 100644 --- a/tests/unit/test_viz/test_widget.py +++ b/tests/unit/test_viz/test_widget.py @@ -1,6 +1,9 @@ -"""Tests for Clustergram widget with Parquet input.""" +"""Tests for Clustergram and Landscape widgets with Parquet input.""" +import io import json +from unittest.mock import patch +import warnings import numpy as np import pandas as pd @@ -17,6 +20,16 @@ pytest.skip(f"celldega modules unavailable: {e}", allow_module_level=True) +def test_landscape_deprecated_technology_argument_warning(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _ = Landscape(technology="MERSCOPE") + messages = [str(warn.message) for warn in w] + assert any("deprecated" in msg.lower() for msg in messages), ( + "Expected deprecation warning for `technology` argument" + ) + + def make_simple_matrix() -> Matrix: np.random.seed(0) df = pd.DataFrame(np.random.rand(4, 5)) @@ -41,7 +54,7 @@ def test_export_viz_parquet_returns_bytes() -> None: assert set(pq) == expected_keys for key in expected_keys - {"meta"}: assert isinstance(pq[key], bytes | bytearray) - assert pq[key] # non-empty + assert pq[key] # ensure non-empty assert isinstance(pq["meta"], dict) @@ -50,11 +63,8 @@ def test_clustergram_initializes_with_parquet() -> None: pq = mat.export_viz_parquet() widget = Clustergram(matrix=mat) - - # Confirm meta is set correctly assert widget.network_meta == pq["meta"] - # Confirm dynamic parquet attributes exist and match expected values for attr, key in [ ("mat_parquet", "mat"), ("row_nodes_parquet", "row_nodes"), @@ -63,15 +73,12 @@ def test_clustergram_initializes_with_parquet() -> None: ("col_linkage_parquet", "col_linkage"), ]: assert hasattr(widget, attr), f"Missing attribute: {attr}" - assert getattr(widget, attr) == pq[key], ( - f"Attribute {attr} does not match expected parquet value" - ) + assert getattr(widget, attr) == pq[key] def test_clustergram_selected_genes_trait() -> None: mat = make_simple_matrix() widget = Clustergram(matrix=mat) - assert widget.selected_genes == [] assert widget.top_n_genes == 50 @@ -79,7 +86,27 @@ def test_clustergram_selected_genes_trait() -> None: assert widget.selected_genes == ["A", "B"] -def test_landscape_nbhd_geojson_and_metadata() -> None: +# ---------- Landscape Patch and Tests ---------- + + +class MockHTTPResponse(io.BytesIO): + def __init__(self, data: bytes): + super().__init__(data) + self.headers = {} # Mimic real HTTPResponse + + +def mock_urlopen_with_technology(*args, **kwargs): + """Valid JSON containing technology.""" + return MockHTTPResponse(json.dumps({"technology": "Xenium"}).encode("utf-8")) + + +def mock_urlopen_missing_technology(*args, **kwargs): + """JSON missing the technology field.""" + return MockHTTPResponse(json.dumps({}).encode("utf-8")) + + +@patch("celldega.viz.widget.urllib.request.urlopen", side_effect=mock_urlopen_with_technology) +def test_landscape_nbhd_geojson_and_metadata(mock_urlopen) -> None: gdf = gpd.GeoDataFrame( {"name": ["a"], "cat": ["x"]}, geometry=[Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])], @@ -87,8 +114,6 @@ def test_landscape_nbhd_geojson_and_metadata() -> None: meta_nbhd = pd.DataFrame({"area": [1]}, index=["a"]) widget = Landscape(nbhd=gdf, meta_nbhd=meta_nbhd) - - # drop geometry_pixel column from gdf gdf = gdf.drop(columns=["geometry_pixel"], errors="ignore") assert widget.nbhd_geojson == json.loads(gdf.to_json())