Skip to content

Visualization

The dissmodel.visualization module provides graphical and interactive representations of running simulations. All visualization components inherit from Model and are therefore integrated into the simulation clock — they update automatically at each step.

Three main components are available:

Component Substrate Description
Chart Any Time-series plots from tracked model variables
Map Vector (GeoDataFrame) Dynamic spatial maps updated each step
RasterMap Raster (NumPy) Raster array rendering — categorical or continuous

All three support three output targets: local matplotlib window, Jupyter inline display, and Streamlit st.empty() placeholder.


@track_plot

The track_plot decorator marks model attributes to be collected and plotted by Chart. Each call defines the variable label, colour, and plot type.

from dissmodel.core import Model
from dissmodel.visualization import track_plot

@track_plot("Susceptible", "green")
@track_plot("Infected",    "red")
@track_plot("Recovered",   "blue")
class SIR(Model):

    def setup(self, susceptible=9998, infected=2, recovered=0,
              duration=2, contacts=6, probability=0.25):
        self.susceptible = susceptible
        self.infected    = infected
        self.recovered   = recovered
        self.duration    = duration
        self.contacts    = contacts
        self.probability = probability

    def execute(self):
        total   = self.susceptible + self.infected + self.recovered
        alpha   = self.contacts * self.probability
        new_inf = self.infected * alpha * (self.susceptible / total)
        new_rec = self.infected / self.duration
        self.susceptible -= new_inf
        self.infected    += new_inf - new_rec
        self.recovered   += new_rec

Chart

Displays time-series data from variables annotated with @track_plot.

from dissmodel.core import Environment
# SIR ships with dissmodel-sysdyn:
# pip install "git+https://github.com/DisSModel/dissmodel-sysdyn.git"
from dissmodel_sysdyn.models import SIR
from dissmodel.visualization import Chart

env = Environment(end_time=30)
SIR()
Chart(show_legend=True)
env.run()

Streamlit:

Chart(plot_area=st.empty())

Map

Renders spatial data from a GeoDataFrame, updated at every simulation step.

from dissmodel.visualization.map  import Map
from matplotlib.colors import ListedColormap

Map(
    gdf=gdf,
    plot_params={
        "column": "state",
        "cmap": ListedColormap(["white", "black"]),
        "ec": "gray",
    },
)

RasterMap

Renders a named NumPy array from a RasterBackend. Supports categorical (value → colour mapping) and continuous (colormap + colorbar) modes.

Categorical:

from dissmodel.visualization.raster_map import RasterMap

RasterMap(
    backend   = b,
    band      = "uso",
    title     = "Land Use",
    color_map = {1: "#006400", 3: "#00008b", 5: "#d2b48c"},
    labels    = {1: "Mangrove", 3: "Sea", 5: "Bare soil"},
)

Continuous:

RasterMap(
    backend        = b,
    band           = "alt",
    title          = "Altimetry",
    cmap           = "terrain",
    colorbar_label = "Altitude (m)",
    mask_band      = "uso",
    mask_value     = 3,      # mask SEA cells
)

Headless (default when no display is available): frames are saved to raster_map_frames/<band>_step_NNN.png.


display_inputs

Generates Streamlit input widgets automatically from a model's type annotations. Integer and float attributes become sliders; booleans become checkboxes.

from dissmodel.visualization import display_inputs

sir = SIR()
display_inputs(sir, st.sidebar)

Full Streamlit example

import streamlit as st
from dissmodel.core import Environment
# SIR ships with dissmodel-sysdyn:
# pip install "git+https://github.com/DisSModel/dissmodel-sysdyn.git"
from dissmodel_sysdyn.models import SIR
from dissmodel.visualization import Chart, display_inputs

st.set_page_config(page_title="SIR Model", layout="centered")
st.title("SIR Model — DisSModel")

st.sidebar.title("Parameters")
steps   = st.sidebar.slider("Steps", min_value=1, max_value=50, value=10)
run_btn = st.button("Run")

env = Environment(end_time=steps, start_time=0)
sir = SIR()
display_inputs(sir, st.sidebar)
Chart(plot_area=st.empty())

if run_btn:
    env.run()

API Reference

dissmodel.visualization.chart.Chart

Bases: Model

Simulation model that renders a live time-series chart.

Extends :class:~dissmodel.core.Model and redraws the chart at every time step. Supports four rendering targets:

  • Streamlit — pass a plot_area (st.empty()).
  • Jupyter — detected automatically via :func:is_notebook.
  • Colab — detected automatically via :func:is_notebook.
  • Matplotlib window — fallback for plain Python scripts.

See :meth:setup for the keyword arguments accepted by this component.

Examples:

from dissmodel.core import Environment

env = Environment(end_time=30)
Chart(show_legend=True, show_grid=True, title="SIR Model")
env.run()
Source code in dissmodel/visualization/chart.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class Chart(Model):
    """
    Simulation model that renders a live time-series chart.

    Extends :class:`~dissmodel.core.Model` and redraws the chart at every
    time step. Supports four rendering targets:

    - **Streamlit** — pass a ``plot_area`` (``st.empty()``).
    - **Jupyter**   — detected automatically via :func:`is_notebook`.
    - **Colab**     — detected automatically via :func:`is_notebook`.
    - **Matplotlib window** — fallback for plain Python scripts.

    See :meth:`setup` for the keyword arguments accepted by this component.

    Examples
    --------
    ```python
    from dissmodel.core import Environment

    env = Environment(end_time=30)
    Chart(show_legend=True, show_grid=True, title="SIR Model")
    env.run()
    ```
    """

    fig: matplotlib.figure.Figure
    ax: matplotlib.axes.Axes
    select: Optional[list[str]]
    interval: int
    time_points: list[float]
    pause: bool
    plot_area: Any
    show_legend: bool
    show_grid: bool
    title: str
    save_frames: bool

    # Narrowing the base Model.setup(**kwargs) contract is intentional:
    # Model.__init__ forwards extra kwargs to setup, and each component
    # declares the keywords it accepts.
    def setup(  # type: ignore[override]
        self,
        select: Optional[list[str]] = None,
        pause: bool = True,
        plot_area: Any = None,
        show_legend: bool = True,
        show_grid: bool = False,
        title: str = "Variable History",
        save_frames: bool = False,
    ) -> None:
        """
        Configure the chart component.

        Called automatically by ``Model.__init__`` with any keyword
        arguments passed to the constructor.

        Parameters
        ----------
        select : list of str, optional
            Subset of labels to plot. If ``None``, all tracked variables are shown.
        pause : bool, optional
            If ``True``, call ``plt.pause()`` after each update, by default ``True``.
            Required for live updates outside notebooks.
        plot_area : any, optional
            Streamlit ``st.empty()`` placeholder, by default ``None``.
        show_legend : bool, optional
            Whether to display the plot legend, by default ``True``.
        show_grid : bool, optional
            Whether to display the plot grid, by default ``False``.
        title : str, optional
            Chart title, by default ``"Variable History"``.
        save_frames : bool, optional
            Save one PNG per step to ``chart_frames/`` in headless mode.
            Default: ``False``.
        """
        self.select      = select
        self.interval    = 1
        self.time_points = []
        self.pause       = pause
        self.plot_area   = plot_area
        self.show_legend = show_legend
        self.show_grid   = show_grid
        self.title       = title
        self.save_frames = save_frames

        # widget Output ancorado — elimina blink no Jupyter/Colab
        self._out = None
        if is_notebook():
            try:
                import ipywidgets as widgets
                from IPython.display import display
                self._out = widgets.Output()
                display(self._out)
            except ImportError:
                # ipywidgets is optional (pip install dissmodel[viz]);
                # without it the clear_output fallback path is used.
                pass

        if not is_notebook():
            self.fig, self.ax = plt.subplots()
            self.ax.set_xlabel("Time")
            self.ax.set_title(self.title)

    # ── rendering ─────────────────────────────────────────────────────────────

    def _render(self) -> matplotlib.figure.Figure:
        if is_notebook():
            self.fig, self.ax = plt.subplots()
            self.ax.set_xlabel("Time")
            self.ax.set_title(self.title)

        plt.sca(self.ax)
        self.time_points.append(self.env.now())

        plot_metadata: dict[str, Any] = getattr(self.env, "_plot_metadata", {})

        self.ax.clear()
        self.ax.set_xlabel("Time")
        self.ax.set_title(self.title)

        for label, info in plot_metadata.items():
            if self.select is None or label in self.select:
                self.ax.plot(info["data"], label=label, color=info["color"])

        if self.show_grid:
            self.ax.grid(True)

        if self.show_legend:
            self.ax.legend()

        self.ax.relim()
        self.ax.autoscale_view()
        plt.tight_layout()
        plt.draw()
        return self.fig

    def _save_frame(self, fig: matplotlib.figure.Figure, step: float) -> None:
        out_dir = pathlib.Path("chart_frames")
        out_dir.mkdir(exist_ok=True)
        fname = out_dir / f"chart_step_{int(step):03d}.png"
        fig.savefig(fname, dpi=100, bbox_inches="tight",
                    facecolor=fig.get_facecolor())
        end_time = getattr(self.env, "end_time", step)
        if int(step) % 10 == 0 or step == end_time:
            print(f"  Chart step {int(step):3d}{fname}")

    # ── execute ───────────────────────────────────────────────────────────────

    def execute(self) -> None:
        step = self.env.now()
        fig  = self._render()

        if self.plot_area is not None:
            # Streamlit
            self.plot_area.pyplot(fig)

        elif is_notebook():
            from IPython.display import clear_output, display
            if self._out is not None:
                # Output widget ancorado — sem blink
                with self._out:
                    clear_output(wait=True)
                    display(fig)
            else:
                # fallback: ipywidgets ausente
                clear_output(wait=True)
                buf = io.BytesIO()
                fig.savefig(buf, format="png")
                buf.seek(0)
                from IPython.display import Image
                display(Image(data=buf.read()))
            plt.close(fig)

        elif self.save_frames or not is_interactive_backend():
            # headless / CI — salva frames silenciosamente
            self._save_frame(fig, step)

        else:
            # interactive window
            if self.pause:
                if is_interactive_backend():
                    plt.pause(0.1)
                    end_time = getattr(self.env, "end_time", step)
                    if step == end_time:
                        plt.show()

setup(select=None, pause=True, plot_area=None, show_legend=True, show_grid=False, title='Variable History', save_frames=False)

Configure the chart component.

Called automatically by Model.__init__ with any keyword arguments passed to the constructor.

Parameters:

Name Type Description Default
select list of str

Subset of labels to plot. If None, all tracked variables are shown.

None
pause bool

If True, call plt.pause() after each update, by default True. Required for live updates outside notebooks.

True
plot_area any

Streamlit st.empty() placeholder, by default None.

None
show_legend bool

Whether to display the plot legend, by default True.

True
show_grid bool

Whether to display the plot grid, by default False.

False
title str

Chart title, by default "Variable History".

'Variable History'
save_frames bool

Save one PNG per step to chart_frames/ in headless mode. Default: False.

False
Source code in dissmodel/visualization/chart.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def setup(  # type: ignore[override]
    self,
    select: Optional[list[str]] = None,
    pause: bool = True,
    plot_area: Any = None,
    show_legend: bool = True,
    show_grid: bool = False,
    title: str = "Variable History",
    save_frames: bool = False,
) -> None:
    """
    Configure the chart component.

    Called automatically by ``Model.__init__`` with any keyword
    arguments passed to the constructor.

    Parameters
    ----------
    select : list of str, optional
        Subset of labels to plot. If ``None``, all tracked variables are shown.
    pause : bool, optional
        If ``True``, call ``plt.pause()`` after each update, by default ``True``.
        Required for live updates outside notebooks.
    plot_area : any, optional
        Streamlit ``st.empty()`` placeholder, by default ``None``.
    show_legend : bool, optional
        Whether to display the plot legend, by default ``True``.
    show_grid : bool, optional
        Whether to display the plot grid, by default ``False``.
    title : str, optional
        Chart title, by default ``"Variable History"``.
    save_frames : bool, optional
        Save one PNG per step to ``chart_frames/`` in headless mode.
        Default: ``False``.
    """
    self.select      = select
    self.interval    = 1
    self.time_points = []
    self.pause       = pause
    self.plot_area   = plot_area
    self.show_legend = show_legend
    self.show_grid   = show_grid
    self.title       = title
    self.save_frames = save_frames

    # widget Output ancorado — elimina blink no Jupyter/Colab
    self._out = None
    if is_notebook():
        try:
            import ipywidgets as widgets
            from IPython.display import display
            self._out = widgets.Output()
            display(self._out)
        except ImportError:
            # ipywidgets is optional (pip install dissmodel[viz]);
            # without it the clear_output fallback path is used.
            pass

    if not is_notebook():
        self.fig, self.ax = plt.subplots()
        self.ax.set_xlabel("Time")
        self.ax.set_title(self.title)

dissmodel.visualization.map.Map

Bases: Model

Simulation model that renders a live choropleth map of a GeoDataFrame.

See :meth:setup for the keyword arguments accepted by this component.

Examples:

from dissmodel.core import Environment

# grid: a GeoDataFrame (e.g. from vector_grid or read_file)
env = Environment(end_time=10)
Map(gdf=grid, plot_params={"column": "state", "cmap": "viridis"})
env.run()
Source code in dissmodel/visualization/map.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class Map(Model):
    """
    Simulation model that renders a live choropleth map of a GeoDataFrame.

    See :meth:`setup` for the keyword arguments accepted by this component.

    Examples
    --------
    ```python
    from dissmodel.core import Environment

    # grid: a GeoDataFrame (e.g. from vector_grid or read_file)
    env = Environment(end_time=10)
    Map(gdf=grid, plot_params={"column": "state", "cmap": "viridis"})
    env.run()
    ```
    """

    # Narrowing the base Model.setup(**kwargs) contract is intentional:
    # Model.__init__ forwards extra kwargs to setup, and each component
    # declares the keywords it accepts.
    def setup(  # type: ignore[override]
        self,
        gdf:         gpd.GeoDataFrame,
        plot_params: dict[str, Any],
        figsize:     tuple[int, int] = (10, 6),
        pause:       bool            = True,
        interval:    float           = 0.01,
        plot_area:   Any             = None,
        save_frames: bool            = False,
    ) -> None:
        """
        Configure the map component.

        Called automatically by ``Model.__init__`` with any keyword
        arguments passed to the constructor.

        Parameters
        ----------
        gdf : geopandas.GeoDataFrame
            GeoDataFrame to render.
        plot_params : dict
            Keyword arguments forwarded to :meth:`GeoDataFrame.plot`.
        figsize : tuple[int, int]
            Figure size in inches. Default: ``(10, 6)``.
        pause : bool
            Call ``plt.pause()`` after each update in interactive mode.
        interval : float
            Seconds passed to ``plt.pause()``. Default: ``0.01``.
        plot_area : st.empty() | None
            Streamlit placeholder. Default: ``None``.
        save_frames : bool
            Save one PNG per step to ``map_frames/``. Default: ``False``.
        """
        self.gdf         = gdf
        self.plot_params = plot_params
        self.figsize     = figsize
        self.pause       = pause
        self.interval    = interval
        self.plot_area   = plot_area
        self.save_frames = save_frames

        # widget Output ancorado — elimina blink no Jupyter/Colab
        # criado uma vez no setup; display() a seguir fixa a posição na célula
        self._out = None
        if is_notebook():
            try:
                import ipywidgets as widgets
                from IPython.display import display
                self._out = widgets.Output()
                display(self._out)
            except ImportError:
                # ipywidgets is optional (pip install dissmodel[viz]);
                # without it the clear_output fallback path is used.
                pass

        # always create fig so _render() can always call self.fig.clf()
        self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)

        # close immediately if not needed for interactive mode
        if is_notebook() or plot_area is not None:
            plt.close(self.fig)

    # ── rendering ─────────────────────────────────────────────────────────────

    def _render(self, step: float) -> matplotlib.figure.Figure:
        if is_notebook() or self.plot_area is not None:
            # create a fresh figure every step
            self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)
        else:
            # reuse existing figure — clear and redraw
            self.fig.clf()
            self.ax = self.fig.add_subplot(1, 1, 1)

        self.gdf.plot(ax=self.ax, **self.plot_params)
        self.ax.set_title(f"Map — Step {int(step)}")
        plt.tight_layout()
        plt.draw()
        return self.fig

    def _save_frame(self, fig: matplotlib.figure.Figure, step: float) -> None:
        col     = self.plot_params.get("column", "map")
        out_dir = pathlib.Path("map_frames")
        out_dir.mkdir(exist_ok=True)
        fname = out_dir / f"{col}_step_{int(step):03d}.png"
        fig.savefig(fname, dpi=100, bbox_inches="tight",
                    facecolor=fig.get_facecolor())
        plt.close(fig)
        end_time = getattr(self.env, "end_time", step)
        if int(step) % 10 == 0 or step == end_time:
            print(f"  Map [{col}] step {int(step):3d}{fname}")

    # ── execute ───────────────────────────────────────────────────────────────

    def execute(self) -> None:
        step = self.env.now()
        fig  = self._render(step)

        if self.plot_area is not None:
            # Streamlit
            self.plot_area.pyplot(fig)
            plt.close(fig)

        elif is_notebook():
            from IPython.display import clear_output, display
            if self._out is not None:
                # Output widget ancorado — sem blink
                with self._out:
                    clear_output(wait=True)
                    display(fig)
            else:
                # fallback: ipywidgets ausente
                clear_output(wait=True)
                display(fig)
            plt.close(fig)

        elif self.save_frames or not is_interactive_backend():
            # headless / CI
            self._save_frame(fig, step)

        else:
            # interactive window
            if self.pause:
                plt.pause(self.interval)
            end_time = getattr(self.env, "end_time", step)
            if step == end_time:
                plt.show()

setup(gdf, plot_params, figsize=(10, 6), pause=True, interval=0.01, plot_area=None, save_frames=False)

Configure the map component.

Called automatically by Model.__init__ with any keyword arguments passed to the constructor.

Parameters:

Name Type Description Default
gdf GeoDataFrame

GeoDataFrame to render.

required
plot_params dict

Keyword arguments forwarded to :meth:GeoDataFrame.plot.

required
figsize tuple[int, int]

Figure size in inches. Default: (10, 6).

(10, 6)
pause bool

Call plt.pause() after each update in interactive mode.

True
interval float

Seconds passed to plt.pause(). Default: 0.01.

0.01
plot_area empty() | None

Streamlit placeholder. Default: None.

None
save_frames bool

Save one PNG per step to map_frames/. Default: False.

False
Source code in dissmodel/visualization/map.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def setup(  # type: ignore[override]
    self,
    gdf:         gpd.GeoDataFrame,
    plot_params: dict[str, Any],
    figsize:     tuple[int, int] = (10, 6),
    pause:       bool            = True,
    interval:    float           = 0.01,
    plot_area:   Any             = None,
    save_frames: bool            = False,
) -> None:
    """
    Configure the map component.

    Called automatically by ``Model.__init__`` with any keyword
    arguments passed to the constructor.

    Parameters
    ----------
    gdf : geopandas.GeoDataFrame
        GeoDataFrame to render.
    plot_params : dict
        Keyword arguments forwarded to :meth:`GeoDataFrame.plot`.
    figsize : tuple[int, int]
        Figure size in inches. Default: ``(10, 6)``.
    pause : bool
        Call ``plt.pause()`` after each update in interactive mode.
    interval : float
        Seconds passed to ``plt.pause()``. Default: ``0.01``.
    plot_area : st.empty() | None
        Streamlit placeholder. Default: ``None``.
    save_frames : bool
        Save one PNG per step to ``map_frames/``. Default: ``False``.
    """
    self.gdf         = gdf
    self.plot_params = plot_params
    self.figsize     = figsize
    self.pause       = pause
    self.interval    = interval
    self.plot_area   = plot_area
    self.save_frames = save_frames

    # widget Output ancorado — elimina blink no Jupyter/Colab
    # criado uma vez no setup; display() a seguir fixa a posição na célula
    self._out = None
    if is_notebook():
        try:
            import ipywidgets as widgets
            from IPython.display import display
            self._out = widgets.Output()
            display(self._out)
        except ImportError:
            # ipywidgets is optional (pip install dissmodel[viz]);
            # without it the clear_output fallback path is used.
            pass

    # always create fig so _render() can always call self.fig.clf()
    self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)

    # close immediately if not needed for interactive mode
    if is_notebook() or plot_area is not None:
        plt.close(self.fig)

dissmodel.visualization.raster_map.RasterMap

Bases: Model

Visualization model for RasterBackend.

See :meth:setup for the keyword arguments accepted by this component.

Categorical mode (color_map provided)

color_map : dict[int, str] {value: "#rrggbb"} labels : dict[int, str] {value: "label"} — used in the legend.

Continuous mode (color_map absent)

cmap : str Matplotlib colormap name. Default: "viridis". scheme : str "manual" — use vmin / vmax (default). "equal_interval" — divide [min, max] of valid data into k classes. "quantiles" — p2–p98 of valid data, robust to outliers. k : int Number of colour classes for scheme="equal_interval". Default: 5. vmin, vmax : float | None Bounds for scheme="manual". legend : bool Show the colorbar. Default: True. colorbar_label : str Colorbar label. Default: band. mask_band : str | None Additional domain mask (e.g. mask sea cells for altimetry). Applied on top of the automatic extent mask. mask_value : int | float | None Value in mask_band to mask.

Source code in dissmodel/visualization/raster_map.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
class RasterMap(Model):
    """
    Visualization model for RasterBackend.

    See :meth:`setup` for the keyword arguments accepted by this component.

    Categorical mode  (``color_map`` provided)
    -------------------------------------------
    color_map : dict[int, str]
        ``{value: "#rrggbb"}``
    labels : dict[int, str]
        ``{value: "label"}`` — used in the legend.

    Continuous mode  (``color_map`` absent)
    ----------------------------------------
    cmap : str
        Matplotlib colormap name. Default: ``"viridis"``.
    scheme : str
        ``"manual"``         — use ``vmin`` / ``vmax`` (default).
        ``"equal_interval"`` — divide [min, max] of valid data into ``k`` classes.
        ``"quantiles"``      — p2–p98 of valid data, robust to outliers.
    k : int
        Number of colour classes for ``scheme="equal_interval"``. Default: ``5``.
    vmin, vmax : float | None
        Bounds for ``scheme="manual"``.
    legend : bool
        Show the colorbar. Default: ``True``.
    colorbar_label : str
        Colorbar label. Default: ``band``.
    mask_band : str | None
        Additional domain mask (e.g. mask sea cells for altimetry).
        Applied on top of the automatic extent mask.
    mask_value : int | float | None
        Value in ``mask_band`` to mask.
    """

    # Created lazily in _render so each instance owns its own figure.
    _fig: matplotlib.figure.Figure | None
    _ax: Any

    # Narrowing the base Model.setup(**kwargs) contract is intentional:
    # Model.__init__ forwards extra kwargs to setup, and each component
    # declares the keywords it accepts.
    def setup(  # type: ignore[override]
        self,
        backend,
        band:            str              = "state",
        title:           str              = "RasterMap",
        figsize:         tuple[int, int]  = (7, 7),
        pause:           bool             = True,
        interval:        float            = 0.5,
        plot_area:       Any              = None,
        auto_mask:       bool             = True,
        save_frames:     bool             = False,
        # categorical
        color_map:       dict[int, str] | None = None,
        labels:          dict[int, str] | None = None,
        # continuous
        cmap:            str              = "viridis",
        scheme:          str              = "manual",
        k:               int              = 5,
        vmin:            float | None     = None,
        vmax:            float | None     = None,
        legend:          bool             = True,
        colorbar_label:  str | None       = None,
        mask_band:       str | None       = None,
        mask_value:      int | float | None = None,
    ) -> None:
        """
        Configure the raster map component.

        Called automatically by ``Model.__init__`` with any keyword
        arguments passed to the constructor. Mode-specific keywords
        (categorical vs. continuous) are described in the class docstring.

        Parameters
        ----------
        backend : RasterBackend
            Backend whose arrays will be rendered.
        band : str
            Array to visualize.
        title : str
            Figure title prefix. Default: ``"RasterMap"``.
        figsize : tuple[int, int]
            Default: ``(7, 7)``.
        pause : bool
            Use ``plt.pause()`` in interactive mode. Default: ``True``.
        interval : float
            Seconds between steps in interactive mode. Default: ``0.5``.
        plot_area : st.empty() | None
            Streamlit placeholder.
        auto_mask : bool
            Apply the backend's extent mask automatically so pixels outside
            the study area are transparent. Default: ``True``.
            Set to ``False`` to restore the pre-v2 behaviour.
        """
        self.backend        = backend
        self.band           = band
        self.title          = title
        self.figsize        = figsize
        self.pause          = pause
        self.interval       = interval
        self.plot_area      = plot_area
        self.auto_mask      = auto_mask
        self.save_frames    = save_frames
        self.color_map      = color_map
        self.labels         = labels or {}
        self.cmap           = cmap
        self.scheme         = scheme
        self.k              = k
        self.vmin           = vmin
        self.vmax           = vmax
        self.legend         = legend
        self.colorbar_label = colorbar_label or band
        self.mask_band      = mask_band
        self.mask_value     = mask_value

        # resolve extent mask once at setup — not on every frame
        self._extent_mask: np.ndarray | None = (
            _get_nodata_mask(backend) if auto_mask else None
        )

        # widget Output ancorado — elimina blink no Jupyter/Colab
        self._out = None
        if is_notebook():
            try:
                import ipywidgets as widgets
                from IPython.display import display
                self._out = widgets.Output()
                display(self._out)
            except ImportError:
                # ipywidgets is optional (pip install dissmodel[viz]);
                # without it the clear_output fallback path is used.
                pass

    # ── rendering ─────────────────────────────────────────────────────────────

    def _render(self, step: float) -> matplotlib.figure.Figure:
        # each RasterMap owns its own figure — reuse across steps
        # so multiple instances show as separate windows simultaneously
        if not hasattr(self, "_fig") or self._fig is None \
                or not plt.fignum_exists(self._fig.number):
            self._fig, self._ax = plt.subplots(figsize=self.figsize)
        else:
            self._fig.clf()
            self._ax = self._fig.add_subplot(1, 1, 1)

        fig, ax = self._fig, self._ax

        arr = self.backend.arrays.get(self.band)
        if arr is None:
            ax.text(0.5, 0.5, f"band '{self.band}' not found",
                    ha="center", va="center", transform=ax.transAxes)
            ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}")
            return fig

        if self.color_map:
            self._render_categorical(ax, arr, self.color_map)
        else:
            self._render_continuous(ax, arr)

        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}")
        plt.tight_layout()
        return fig

    def _apply_masks(self, data: np.ndarray) -> np.ma.MaskedArray:
        """
        Aplica todas as máscaras em ordem e retorna um MaskedArray:
        1. extent mask automático (auto_mask=True)
        2. mask_band / mask_value  (domínio, ex: mar para altimetria)
        3. NaN / Inf residuais
        """
        # 1. extent mask — pixels fora do estudo
        if self._extent_mask is not None:
            data = np.where(self._extent_mask, data, np.nan)

        # 2. domain mask — ex: mascarar mar na visualização de altimetria
        if self.mask_band is not None and self.mask_value is not None:
            mask_arr = self.backend.arrays.get(self.mask_band)
            if mask_arr is not None:
                data = np.where(mask_arr == self.mask_value, np.nan, data)

        # 3. cobre NaN / Inf (inclui os inseridos pelos passos acima)
        return np.ma.masked_invalid(data)

    def _render_categorical(
        self, ax, arr: np.ndarray, color_map: dict[int, str]
    ) -> None:
        vals = sorted(color_map)
        cmap = mcolors.ListedColormap([color_map[v] for v in vals])
        norm = mcolors.BoundaryNorm(
            [v - 0.5 for v in vals] + [vals[-1] + 0.5], cmap.N
        )
        cmap.set_bad(color="white", alpha=0)
        data = self._apply_masks(arr.astype(float))

        ax.imshow(data, cmap=cmap, norm=norm, aspect="equal", interpolation="nearest")

        present = set(np.unique(arr[~np.isnan(arr.astype(float))]))
        patches = [
            matplotlib.patches.Patch(color=color_map[v],
                                     label=self.labels.get(v, str(v)))
            for v in vals if v in present
        ]
        if patches:
            ax.legend(handles=patches, loc="lower right", fontsize=7, framealpha=0.7)

    def _render_continuous(self, ax, arr: np.ndarray) -> None:
        data  = self._apply_masks(arr.astype(float))
        cmap  = plt.get_cmap(self.cmap).copy()
        cmap.set_bad(color="white", alpha=0)

        valid = data.compressed()

        if len(valid) == 0:
            vmin, vmax = 0.0, 1.0

        elif self.scheme == "equal_interval":
            vmin = float(valid.min())
            vmax = float(valid.max())
            if vmin != vmax and self.k > 1:
                bounds = np.linspace(vmin, vmax, self.k + 1)
                norm   = mcolors.BoundaryNorm(bounds, plt.get_cmap(self.cmap).N)
                im = ax.imshow(data, cmap=cmap, norm=norm, aspect="equal",
                               interpolation="nearest")
                if self.legend:
                    plt.colorbar(im, ax=ax, label=self.colorbar_label,
                                 fraction=0.03, pad=0.02)
                return

        elif self.scheme == "quantiles":
            vmin = float(np.percentile(valid, 2))
            vmax = float(np.percentile(valid, 98))

        else:   # "manual"
            vmin = self.vmin if self.vmin is not None else float(valid.min())
            vmax = self.vmax if self.vmax is not None else float(valid.max())

        if vmin == vmax:
            vmax = vmin + 1.0

        im = ax.imshow(data, cmap=cmap, aspect="equal",
                       interpolation="nearest", vmin=vmin, vmax=vmax)
        if self.legend:
            plt.colorbar(im, ax=ax, label=self.colorbar_label,
                         fraction=0.03, pad=0.02)

    # ── execute ───────────────────────────────────────────────────────────────

    def execute(self) -> None:
        step = self.env.now()
        fig  = self._render(step)
        plt.draw()

        if self.plot_area is not None:
            # Streamlit
            self.plot_area.pyplot(fig)
            plt.close(fig)

        elif is_notebook():
            from IPython.display import clear_output, display
            if self._out is not None:
                # Output widget ancorado — sem blink
                with self._out:
                    clear_output(wait=True)
                    display(fig)
            else:
                # fallback: ipywidgets ausente
                clear_output(wait=True)
                display(fig)
            plt.close(fig)

        elif self.save_frames or not is_interactive_backend():
            # headless / CI
            out_dir = pathlib.Path("raster_map_frames")
            out_dir.mkdir(exist_ok=True)
            fname = out_dir / f"{self.band}_step_{int(step):03d}.png"
            fig.savefig(fname, dpi=100, bbox_inches="tight",
                        facecolor=fig.get_facecolor())
            plt.close(fig)
            end_time = getattr(self.env, "end_time", step)
            if int(step) % 10 == 0 or step == end_time:
                print(f"  RasterMap [{self.band}] step {int(step):3d}{fname}")

        else:
            # interactive window
            if self.pause:
                plt.pause(self.interval)
            end_time = getattr(self.env, "end_time", step)
            if step == end_time:
                plt.show()

setup(backend, band='state', title='RasterMap', figsize=(7, 7), pause=True, interval=0.5, plot_area=None, auto_mask=True, save_frames=False, color_map=None, labels=None, cmap='viridis', scheme='manual', k=5, vmin=None, vmax=None, legend=True, colorbar_label=None, mask_band=None, mask_value=None)

Configure the raster map component.

Called automatically by Model.__init__ with any keyword arguments passed to the constructor. Mode-specific keywords (categorical vs. continuous) are described in the class docstring.

Parameters:

Name Type Description Default
backend RasterBackend

Backend whose arrays will be rendered.

required
band str

Array to visualize.

'state'
title str

Figure title prefix. Default: "RasterMap".

'RasterMap'
figsize tuple[int, int]

Default: (7, 7).

(7, 7)
pause bool

Use plt.pause() in interactive mode. Default: True.

True
interval float

Seconds between steps in interactive mode. Default: 0.5.

0.5
plot_area empty() | None

Streamlit placeholder.

None
auto_mask bool

Apply the backend's extent mask automatically so pixels outside the study area are transparent. Default: True. Set to False to restore the pre-v2 behaviour.

True
Source code in dissmodel/visualization/raster_map.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def setup(  # type: ignore[override]
    self,
    backend,
    band:            str              = "state",
    title:           str              = "RasterMap",
    figsize:         tuple[int, int]  = (7, 7),
    pause:           bool             = True,
    interval:        float            = 0.5,
    plot_area:       Any              = None,
    auto_mask:       bool             = True,
    save_frames:     bool             = False,
    # categorical
    color_map:       dict[int, str] | None = None,
    labels:          dict[int, str] | None = None,
    # continuous
    cmap:            str              = "viridis",
    scheme:          str              = "manual",
    k:               int              = 5,
    vmin:            float | None     = None,
    vmax:            float | None     = None,
    legend:          bool             = True,
    colorbar_label:  str | None       = None,
    mask_band:       str | None       = None,
    mask_value:      int | float | None = None,
) -> None:
    """
    Configure the raster map component.

    Called automatically by ``Model.__init__`` with any keyword
    arguments passed to the constructor. Mode-specific keywords
    (categorical vs. continuous) are described in the class docstring.

    Parameters
    ----------
    backend : RasterBackend
        Backend whose arrays will be rendered.
    band : str
        Array to visualize.
    title : str
        Figure title prefix. Default: ``"RasterMap"``.
    figsize : tuple[int, int]
        Default: ``(7, 7)``.
    pause : bool
        Use ``plt.pause()`` in interactive mode. Default: ``True``.
    interval : float
        Seconds between steps in interactive mode. Default: ``0.5``.
    plot_area : st.empty() | None
        Streamlit placeholder.
    auto_mask : bool
        Apply the backend's extent mask automatically so pixels outside
        the study area are transparent. Default: ``True``.
        Set to ``False`` to restore the pre-v2 behaviour.
    """
    self.backend        = backend
    self.band           = band
    self.title          = title
    self.figsize        = figsize
    self.pause          = pause
    self.interval       = interval
    self.plot_area      = plot_area
    self.auto_mask      = auto_mask
    self.save_frames    = save_frames
    self.color_map      = color_map
    self.labels         = labels or {}
    self.cmap           = cmap
    self.scheme         = scheme
    self.k              = k
    self.vmin           = vmin
    self.vmax           = vmax
    self.legend         = legend
    self.colorbar_label = colorbar_label or band
    self.mask_band      = mask_band
    self.mask_value     = mask_value

    # resolve extent mask once at setup — not on every frame
    self._extent_mask: np.ndarray | None = (
        _get_nodata_mask(backend) if auto_mask else None
    )

    # widget Output ancorado — elimina blink no Jupyter/Colab
    self._out = None
    if is_notebook():
        try:
            import ipywidgets as widgets
            from IPython.display import display
            self._out = widgets.Output()
            display(self._out)
        except ImportError:
            # ipywidgets is optional (pip install dissmodel[viz]);
            # without it the clear_output fallback path is used.
            pass