Source code for parq_blockmodel.visualization.blockmodel_plot

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Protocol, TYPE_CHECKING

import numpy as np
import pandas as pd
import pyvista as pv

from parq_blockmodel.utils.pyvista.categorical_utils import load_mapping_dict
from parq_blockmodel.utils.pyvista.custom_plotter import CustomPlotter

if TYPE_CHECKING:
    from parq_blockmodel.blockmodel import ParquetBlockModel


[docs] @dataclass(slots=True) class BlockModelPlotState: mesh: pv.DataSet scalar: str attributes: list[str] categorical_mappings: dict[str, dict[int, str]] scalar_is_categorical: bool title: str
[docs] class BlockModelPlotEngine(Protocol): def plot( self, blockmodel: "ParquetBlockModel", *, scalar: str, grid_type: str = "image", frame: str = "world", threshold: bool = True, show_edges: bool = True, show_axes: bool = True, enable_picking: bool = False, picked_attributes: Optional[list[str]] = None, z_up_lock: bool = False, z_up_hotkey: str = "z", elevation_raster: Optional[str | Path] = None, imagery_raster: Optional[str | Path] = None, ) -> Any: ...
def _resolve_attributes( blockmodel: "ParquetBlockModel", scalar: str, enable_picking: bool, picked_attributes: Optional[list[str]], ) -> list[str]: if enable_picking: attributes = list(blockmodel.available_attributes if picked_attributes is None else picked_attributes) else: attributes = [scalar] if scalar not in attributes: attributes.append(scalar) return attributes def _build_categorical_mappings(mesh: pv.DataSet, attributes: list[str]) -> dict[str, dict[int, str]]: mappings: dict[str, dict[int, str]] = {} for attr in attributes: if f"{attr}_json" not in mesh.field_data: continue mapping = load_mapping_dict(mesh, attr) mappings[attr] = {int(float(key)): str(value) for key, value in mapping.items()} return mappings
[docs] def prepare_plot_state( blockmodel: "ParquetBlockModel", *, scalar: str, grid_type: str = "image", frame: str = "world", enable_picking: bool = False, picked_attributes: Optional[list[str]] = None, title: Optional[str] = None, ) -> BlockModelPlotState: if scalar not in blockmodel.available_columns: raise ValueError( f"Column '{scalar}' not found in the ParquetBlockModel." f"Available columns are: {blockmodel.available_columns}" ) attributes = _resolve_attributes(blockmodel, scalar, enable_picking, picked_attributes) mesh = blockmodel.to_pyvista(grid_type=grid_type, attributes=attributes, frame=frame) scalar_is_categorical = f"{scalar}_json" in mesh.field_data categorical_mappings = _build_categorical_mappings(mesh, attributes) if enable_picking else {} if scalar_is_categorical and scalar not in categorical_mappings: categorical_mappings.update(_build_categorical_mappings(mesh, [scalar])) return BlockModelPlotState( mesh=mesh, scalar=scalar, attributes=attributes, categorical_mappings=categorical_mappings, scalar_is_categorical=scalar_is_categorical, title=title or blockmodel.name, )
def _calculate_deciles(values: np.ndarray) -> np.ndarray: finite = np.asarray(values, dtype=float) finite = finite[np.isfinite(finite)] if finite.size == 0: return np.asarray([], dtype=float) return np.percentile(finite, np.arange(10, 100, 10)) def _bin_to_deciles(values: np.ndarray, decile_edges: np.ndarray) -> np.ndarray: binned = np.full(values.shape, np.nan, dtype=float) finite_mask = np.isfinite(values) if not np.any(finite_mask): return binned if decile_edges.size == 0: binned[finite_mask] = 0.0 return binned finite_values = np.asarray(values[finite_mask], dtype=float) finite_bins = np.digitize(finite_values, decile_edges, right=False) finite_bins = np.clip(finite_bins, 0, 9) binned[finite_mask] = finite_bins.astype(float) return binned def _colormap_to_lookup_table(colormap: str, n_bins: int = 10) -> pv.LookupTable: try: import matplotlib.pyplot as plt sampled = plt.get_cmap(colormap)(np.linspace(0.0, 1.0, n_bins)) colors = np.asarray(np.clip(np.rint(sampled * 255.0), 0, 255), dtype=np.uint8) except ImportError: fallback = np.array( [ [31, 119, 180, 255], [255, 127, 14, 255], [44, 160, 44, 255], [214, 39, 40, 255], [148, 103, 189, 255], [140, 86, 75, 255], [227, 119, 194, 255], [127, 127, 127, 255], [188, 189, 34, 255], [23, 190, 207, 255], ], dtype=np.uint8, ) colors = np.vstack([fallback[i % len(fallback)] for i in range(n_bins)]) return pv.LookupTable(values=colors, scalar_range=(-0.5, n_bins - 0.5), nan_color="lightgray") def _plotter_add_mesh_kwargs( state: BlockModelPlotState, colormap: str = "viridis", discretize_to_deciles: bool = False, decile_edges: Optional[np.ndarray] = None, scalar_for_coloring: Optional[str] = None, ) -> dict[str, Any]: scalar_name = scalar_for_coloring or state.scalar add_mesh_kwargs: dict[str, Any] = { "scalars": scalar_name, "show_edges": True, } if state.scalar_is_categorical: scalar_mapping = state.categorical_mappings.get(state.scalar, {}) annotations = {float(key): str(value) for key, value in scalar_mapping.items()} n_categories = max(len(scalar_mapping), 1) scalar_values = np.asarray(state.mesh.cell_data[state.scalar], dtype=float) has_nan = bool(np.isnan(scalar_values).any()) scalar_bar_args = {"n_labels": 0} if has_nan: scalar_bar_args["nan_annotation"] = True base_palette = np.array( [ [31, 119, 180, 255], [255, 127, 14, 255], [44, 160, 44, 255], [214, 39, 40, 255], [148, 103, 189, 255], [140, 86, 75, 255], [227, 119, 194, 255], [127, 127, 127, 255], [188, 189, 34, 255], [23, 190, 207, 255], ], dtype=np.uint8, ) lut_values = np.vstack([base_palette[i % len(base_palette)] for i in range(n_categories)]) lut = pv.LookupTable( values=lut_values, scalar_range=(-0.5, n_categories - 0.5), nan_color="lightgray", ) lut.annotations = annotations add_mesh_kwargs.update( { "categories": True, "annotations": annotations, "cmap": lut, "nan_color": "lightgray", "nan_opacity": 0.15, "scalar_bar_args": scalar_bar_args, } ) elif discretize_to_deciles: n_bins = 10 lut = _colormap_to_lookup_table(colormap, n_bins=n_bins) if decile_edges is None: decile_edges = np.asarray([], dtype=float) decile_edges = np.asarray(decile_edges, dtype=float) annotations: dict[float, str] = {} if decile_edges.size == n_bins - 1: labels = [f"<= {decile_edges[0]:.3g}"] for i in range(1, n_bins - 1): labels.append(f"{decile_edges[i - 1]:.3g} - {decile_edges[i]:.3g}") labels.append(f"> {decile_edges[-1]:.3g}") annotations = {float(i): labels[i] for i in range(n_bins)} lut.annotations = annotations scalar_bar_args = {"n_labels": 0, "nan_annotation": True} add_mesh_kwargs.update( { "categories": True, "annotations": annotations, "cmap": lut, "nan_color": "lightgray", "nan_opacity": 0.15, "scalar_bar_args": scalar_bar_args, } ) else: add_mesh_kwargs["cmap"] = colormap return add_mesh_kwargs def _cell_info_callback( plotter: pv.Plotter, state: BlockModelPlotState, text_name: str, ): def cell_callback(picked_cell): if text_name in plotter.actors: plotter.remove_actor(text_name) if hasattr(picked_cell, "n_cells") and picked_cell.n_cells == 1: if "vtkOriginalCellIds" in picked_cell.cell_data: cell_id = int(picked_cell.cell_data["vtkOriginalCellIds"][0]) cell_centers = state.mesh.cell_centers().points centroid = cell_centers[cell_id] centroid_str = f"({centroid[0]:.1f}, {centroid[1]:.1f}, {centroid[2]:.1f})" values: dict[str, Any] = {} for attr in state.attributes: raw_value = state.mesh.cell_data[attr][cell_id] if attr in state.categorical_mappings: if pd.isna(raw_value): values[attr] = "<NA>" else: code = int(np.rint(float(raw_value))) values[attr] = state.categorical_mappings[attr].get(code, f"<unknown:{code}>") else: values[attr] = raw_value msg = f"Cell ID: {cell_id}, {centroid_str}, " + ", ".join(f"{k}: {v}" for k, v in values.items()) else: value = picked_cell.cell_data[state.scalar][0] msg = f"Picked cell value: {state.scalar}: {value}" plotter.add_text(msg, position="upper_left", font_size=12, name=text_name) else: plotter.add_text("No valid cell picked.", position="upper_left", font_size=12, name=text_name) return cell_callback def _build_elevation_surface(elevation_raster: str | Path) -> pv.StructuredGrid: from parq_blockmodel.surface import RasterSurface surface = RasterSurface.from_file(elevation_raster) x_coords = np.asarray(surface._x, dtype=float) y_coords = np.asarray(surface._y, dtype=float) elevation = np.asarray(surface._values, dtype=float) points_x, points_y = np.meshgrid(x_coords, y_coords, indexing="xy") grid = pv.StructuredGrid(points_x, points_y, elevation) grid["elevation"] = elevation.ravel(order="C") return grid def _add_elevation_overlay( plotter: pv.Plotter, *, elevation_raster: Optional[str | Path] = None, imagery_raster: Optional[str | Path] = None, ) -> None: if elevation_raster is None: return dem_surface = _build_elevation_surface(elevation_raster) mesh_kwargs: dict[str, Any] = { "show_edges": False, "name": "pbm_dem_surface", } if imagery_raster is not None: dem_surface.texture_map_to_plane(inplace=True, use_bounds=True) mesh_kwargs["texture"] = pv.read_texture(str(imagery_raster)) else: mesh_kwargs.update( { "scalars": "elevation", "cmap": "terrain", "show_scalar_bar": False, } ) actor = plotter.add_mesh(dem_surface, **mesh_kwargs) if hasattr(plotter, "add_checkbox_button_widget"): def _toggle_dem_surface(visible: bool) -> None: actor.SetVisibility(bool(visible)) if hasattr(plotter, "render"): plotter.render() plotter.add_checkbox_button_widget( _toggle_dem_surface, value=True, position=(10, 10), size=28, color_on="forestgreen", color_off="gray", border_size=2, ) if hasattr(plotter, "add_text"): plotter.add_text("DEM", position=(45, 18), font_size=10, name="pbm_dem_toggle_label")
[docs] def render_plotter( state: BlockModelPlotState, *, threshold: bool = True, show_edges: bool = True, show_axes: bool = True, enable_picking: bool = False, z_up_lock: bool = False, z_up_hotkey: str = "z", elevation_raster: Optional[str | Path] = None, imagery_raster: Optional[str | Path] = None, ) -> pv.Plotter: plotter = CustomPlotter() add_mesh_kwargs = _plotter_add_mesh_kwargs(state) add_mesh_kwargs["show_edges"] = show_edges if threshold: plotter.add_mesh_threshold(state.mesh, **add_mesh_kwargs) else: plotter.add_mesh(state.mesh, **add_mesh_kwargs) plotter.title = state.title if show_axes: plotter.add_axes() plotter.set_directional_view(direction='WSW', elevation_deg=30) if enable_picking: callback = _cell_info_callback(plotter, state, "cell_info_text") plotter.disable_picking() plotter.enable_cell_picking(callback=callback, show_message=False, through=False) _add_elevation_overlay( plotter, elevation_raster=elevation_raster, imagery_raster=imagery_raster, ) return plotter
[docs] class PyVistaBlockModelPlotEngine: def plot( self, blockmodel: "ParquetBlockModel", *, scalar: str, grid_type: str = "image", frame: str = "world", threshold: bool = True, show_edges: bool = True, show_axes: bool = True, enable_picking: bool = False, picked_attributes: Optional[list[str]] = None, z_up_lock: bool = False, z_up_hotkey: str = "z", elevation_raster: Optional[str | Path] = None, imagery_raster: Optional[str | Path] = None, ) -> pv.Plotter: state = prepare_plot_state( blockmodel, scalar=scalar, grid_type=grid_type, frame=frame, enable_picking=enable_picking, picked_attributes=picked_attributes, ) return render_plotter( state, threshold=threshold, show_edges=show_edges, show_axes=show_axes, enable_picking=enable_picking, z_up_lock=z_up_lock, z_up_hotkey=z_up_hotkey, elevation_raster=elevation_raster, imagery_raster=imagery_raster, )
[docs] class TrameBlockModelPlotEngine:
[docs] def __init__( self, *, launch_on_plot: bool = False, server_name: str = "parq-blockmodel-trame", port: Optional[int] = None, host: Optional[str] = None, app_name: str = "ParquetBlockModel Viewer", ) -> None: self.launch_on_plot = launch_on_plot self.server_name = server_name self.port = port self.host = host self.app_name = app_name
def plot( self, blockmodel: "ParquetBlockModel", *, scalar: str, grid_type: str = "image", frame: str = "world", threshold: bool = True, show_edges: bool = True, show_axes: bool = True, enable_picking: bool = False, picked_attributes: Optional[list[str]] = None, z_up_lock: bool = False, z_up_hotkey: str = "z", elevation_raster: Optional[str | Path] = None, imagery_raster: Optional[str | Path] = None, ) -> Any: del threshold, show_axes, enable_picking, picked_attributes, elevation_raster, imagery_raster from parq_blockmodel.visualization.trame_app import BlockModelTrameApp app = BlockModelTrameApp( blockmodel, scalar=scalar, grid_type=grid_type, frame=frame, show_edges=show_edges, z_up_lock=z_up_lock, z_up_hotkey=z_up_hotkey, app_name=self.app_name, ) if self.launch_on_plot: return app.launch(server_name=self.server_name, port=self.port, host=self.host) return app