diff --git a/README.md b/README.md
index 2517af6..91daaa9 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,13 @@ The package is available on PyPI and can be installed using pip:
```sh
pip install fdsreader
```
+
+The Jupyter [explorer](#explorer) needs a plotting stack, which is an optional extra:
+```sh
+pip install "fdsreader[notebook]"
+```
+Its command line counterpart needs nothing beyond numpy and is always installed.
+
_FDS Version 6.7.5 and above are fully supported. Versions below 6.7.5 might work, but are not guaranteed to work._
## Usage example
@@ -60,6 +67,116 @@ fds.settings.DEBUG = True
Beware that not all attributes and methods are covered in this diagram. For a complete
documentation of all classes check the API Documentation below.
+## Explorer
+
+`fdsreader.explorer` looks at a simulation without writing plotting code for it: a time
+bar driving a 2D slice, and any number of device or HRR quantities plotted against time.
+In Jupyter that is an interactive widget; in a terminal you can either explore it
+interactively or draw a single view and exit.
+
+### In Jupyter
+
+```python
+from fdsreader.explorer import explore
+
+explorer = explore()
+```
+
+That renders the whole interface — a directory browser, the time bar, the slice on the
+left and the curves on the right. Browse to a case and press *Load simulation*.
+
+```python
+explore("./sample_data") # skip the browser and load this case
+explore(start_dir="/data/cases") # open the browser somewhere specific
+explore(interactive_canvas=False) # static images instead of ipympl canvases
+explore(caching=True) # allow the .pickle cache (writable data only)
+```
+
+`explore()` returns the explorer, so the simulation and the current selection stay
+available in later cells:
+
+```python
+sim = explorer.sim
+explorer.current_time # where the time bar is, in seconds
+explorer.selection # the curves currently ticked
+explorer.field.frame(400) # the slice on screen, at time step 400
+```
+
+`ipympl` is optional; with it each plot gains a toolbar to zoom, pan and save. If the
+plots come up as `Failed to load model class 'MPLCanvasModel'`, its browser-side
+extension is not loading — pass `interactive_canvas=False`.
+
+### In a terminal
+
+The command line front end needs nothing but numpy, so it works over SSH on a machine
+with no plotting stack and no browser.
+
+`-i` opens a full-screen interactive view, where the slice and the curves are chosen from
+lists rather than named on the command line:
+
+```sh
+fdsreader-explorer-cli ./sample_data -i
+```
+
+| key | |
+|---|---|
+| `←` `→` or `h` `l` | step through time |
+| `page up` / `page down` | ten steps |
+| `home` / `end` | first / last step |
+| `space` | play, pause |
+| `+` `-` | play speed |
+| `f` | pick the slice from a list |
+| `c` | pick the curves from a list (`space` ticks, `enter` accepts) |
+| `[` `]` | previous / next slice |
+| `g` / `s` | colour scale over the whole run / this step |
+| `?` | help |
+| `q` | quit |
+
+Without `-i` it draws one view and exits, which is what you want in a script:
+
+```sh
+fdsreader-explorer-cli ./sample_data # what is in it
+fdsreader-explorer-cli ./sample_data --list # slices and curves by name
+fdsreader-explorer-cli ./sample_data --slice 0 --time 4.4 # draw a slice
+fdsreader-explorer-cli ./sample_data --curve TC_1 --curve HRR
+fdsreader-explorer-cli ./sample_data --json # for scripting
+```
+
+```
+TEMPERATURE [C] — x = 0 m #0 · t = 4.401 s (step 630 of 749)
+
+ 10.00 |
+ |
+ 8.82 |
+ |
+ |
+ | ...
+ | .....
+ 5.88 | .....
+ | ...:...
+ | .....
+ | ....
+ | ...
+ 2.94 | .:..
+ | -.
+ | -:-.
+ | .-*:
+ | +%+.
+ 0.00 | +#:
+ +------------------
+ -2.5 2.5 y [m]
+ scale 20 … 1412 ramp ' .:-=+*#%@' grid 18x18
+```
+
+`--save FILE` writes the current view as an image or an animation instead
+(`.png`/`.pdf`/`.svg`/`.gif`/`.mp4`, needs matplotlib).
+
+The interactive view uses `curses`, which is in the standard library everywhere except
+Windows; there, `pip install windows-curses` provides it.
+
+Slices are read one time step at a time, so stepping through a long run costs a few
+hundred kilobytes rather than loading the whole series into memory.
+
## API Documentation
[https://fdsreader.readthedocs.io](https://fdsreader.readthedocs.io)
diff --git a/docs/conf.py b/docs/conf.py
index 7f9d8bf..db4e4fb 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -37,6 +37,10 @@
# ones.
extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "autodocsumm"]
+# fdsreader.explorer's Jupyter front end needs matplotlib and ipywidgets, which are an
+# optional extra and are not installed for a docs build.
+autodoc_mock_imports = ["matplotlib", "ipywidgets", "ipympl", "IPython"]
+
autoclass_content = "both"
# List of patterns, relative to source directory, that match files and
diff --git a/docs/explorer.rst b/docs/explorer.rst
new file mode 100644
index 0000000..88eaef0
--- /dev/null
+++ b/docs/explorer.rst
@@ -0,0 +1,61 @@
+Explorer
+________
+
+Front ends for looking at a simulation: an interactive widget for Jupyter, and a terminal
+application that either explores it interactively (``-i``) or draws one view and exits.
+The core is importable with nothing but numpy; only the Jupyter front end needs
+``pip install "fdsreader[notebook]"``.
+
+.. automodule:: fdsreader.explorer
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.data
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.fields
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.state
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.plots
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.render
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.notebook
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.tui
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
+
+.. automodule:: fdsreader.explorer.cli
+ :autosummary:
+ :members:
+ :undoc-members:
+ :noindex:
diff --git a/docs/index.rst b/docs/index.rst
index fbf5545..d6c58fe 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -33,6 +33,7 @@ Source code, issue tracker and releases are hosted on
fds_classes
utils
export
+ explorer
.. include:: simulation.rst
.. include:: slcf.rst
@@ -48,3 +49,4 @@ Source code, issue tracker and releases are hosted on
.. include:: fds_classes.rst
.. include:: utils.rst
.. include:: export.rst
+.. include:: explorer.rst
diff --git a/fdsreader/explorer/__init__.py b/fdsreader/explorer/__init__.py
new file mode 100644
index 0000000..34fe076
--- /dev/null
+++ b/fdsreader/explorer/__init__.py
@@ -0,0 +1,103 @@
+"""Front ends for looking at an FDS simulation.
+
+Three of them share one core:
+
+* :mod:`~fdsreader.explorer.notebook` -- widgets for Jupyter, ``explore()``
+* :mod:`~fdsreader.explorer.cli` -- ``fdsreader-explorer-cli``, which draws one view
+ in a terminal and exits, or with ``-i`` hands over to
+ :mod:`~fdsreader.explorer.tui` for a full-screen interactive view
+* a desktop front end may follow; it would sit beside these two
+
+The shared parts are :mod:`~fdsreader.explorer.data` (reading devices, HRR and slices,
+numpy only), :mod:`~fdsreader.explorer.plots` (matplotlib) and
+:mod:`~fdsreader.explorer.render` (characters in a terminal).
+
+Importing this package pulls in nothing beyond numpy. The names that need matplotlib or
+ipywidgets are resolved on first use, so::
+
+ from fdsreader.explorer.cli import main # numpy only
+ from fdsreader.explorer import explore # needs the notebook extras
+"""
+
+from .data import (
+ axis_label as axis_label,
+)
+from .data import (
+ build_series as build_series,
+)
+from .data import (
+ color_limits as color_limits,
+)
+from .data import (
+ device_label as device_label,
+)
+from .data import (
+ device_time as device_time,
+)
+from .data import (
+ hrr_units as hrr_units,
+)
+from .data import (
+ list_devices as list_devices,
+)
+from .data import (
+ load_device_data as load_device_data,
+)
+from .data import (
+ prepare_slice as prepare_slice,
+)
+from .data import (
+ quantity_of as quantity_of,
+)
+from .data import (
+ slice_label as slice_label,
+)
+from .data import (
+ unit_of as unit_of,
+)
+
+#: Names that live in a module with heavier requirements, resolved on first access.
+_LAZY = {
+ "NotebookExplorer": "notebook",
+ "SimulationBrowser": "notebook",
+ "explore": "notebook",
+ "DIVERGING_CMAPS": "plots",
+ "SEQUENTIAL_CMAPS": "plots",
+ "plot_device": "plots",
+ "plot_series": "plots",
+ "plot_slice": "plots",
+ "Interactive": "tui",
+ "RAMP": "render",
+ "series_lines": "render",
+ "slice_lines": "render",
+}
+
+
+def __getattr__(name):
+ """Import the front-end modules only when something from them is asked for."""
+ module = _LAZY.get(name)
+ if module is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ from importlib import import_module
+
+ return getattr(import_module(f".{module}", __name__), name)
+
+
+def __dir__():
+ return sorted(list(globals()) + list(_LAZY))
+
+
+__all__ = sorted(_LAZY) + [
+ "axis_label",
+ "build_series",
+ "color_limits",
+ "device_label",
+ "device_time",
+ "hrr_units",
+ "list_devices",
+ "load_device_data",
+ "prepare_slice",
+ "quantity_of",
+ "slice_label",
+ "unit_of",
+]
diff --git a/fdsreader/explorer/__main__.py b/fdsreader/explorer/__main__.py
new file mode 100644
index 0000000..a6aaf63
--- /dev/null
+++ b/fdsreader/explorer/__main__.py
@@ -0,0 +1,5 @@
+"""Run the command line front end with ``python -m fdsreader.explorer``."""
+
+from .cli import main
+
+raise SystemExit(main())
diff --git a/fdsreader/explorer/cli.py b/fdsreader/explorer/cli.py
new file mode 100644
index 0000000..5d06214
--- /dev/null
+++ b/fdsreader/explorer/cli.py
@@ -0,0 +1,390 @@
+"""Command line front end.
+
+A one-shot renderer: it prints what a simulation contains, or draws one slice time step
+or a set of curves, as text. Needs nothing but numpy, so it works over SSH on a machine
+that has no plotting stack and no browser.
+
+ fdsreader-explorer-cli CASE -i explore it interactively
+ fdsreader-explorer-cli CASE what is in it
+ fdsreader-explorer-cli CASE --slice 0 --time 4.4 draw a slice
+ fdsreader-explorer-cli CASE --curve T_1.0 --curve HRR
+"""
+
+import argparse
+import json
+import sys
+
+import numpy as np
+
+import fdsreader
+from fdsreader import settings
+
+from .data import build_series, list_devices, quantity_of, unit_of
+from .fields import fields_of
+from .render import RAMP, series_lines, slice_lines
+from .state import ExplorerState
+
+PROG = "fdsreader-explorer-cli"
+
+
+def _load(path, caching=False):
+ settings.ENABLE_CACHING = caching
+ return fdsreader.Simulation(str(path))
+
+
+def _state_from(args, fields):
+ """Build the shared state from the command line arguments."""
+ # One picture is best scaled to itself; while stepping through time a fixed scale
+ # keeps the frames comparable.
+ scale = args.scale or ("global" if args.interactive else "step")
+ state = ExplorerState(scale_mode="step")
+ if args.slice is not None:
+ state.field_index = args.slice
+ if scale in ("global", "step"):
+ state.scale_mode = scale
+ else:
+ try:
+ low, high = (float(part) for part in args.scale.split(","))
+ except ValueError:
+ raise SystemExit(f"{PROG}: --scale wants 'global', 'step' or 'LOW,HIGH', not {args.scale!r}")
+ if not low < high:
+ raise SystemExit(f"{PROG}: --scale LOW must be below HIGH")
+ state.scale_mode, state.manual_limits = "manual", (low, high)
+ if args.time is not None and fields:
+ state.seek(fields, args.time)
+ return state
+
+
+def overview(sim):
+ """What the simulation holds, as text lines."""
+ devices = list_devices(sim)
+ series = build_series(sim)
+ n_hrr = sum(1 for s in series if s["source"] == "hrr")
+
+ starts = [s["times"][0] for s in series if len(s["times"])]
+ ends = [s["times"][-1] for s in series if len(s["times"])]
+
+ lines = [f"{sim.chid} {sim.root_path}", ""]
+ lines.append(f" meshes {len(sim.meshes)}")
+ lines.append(f" devices (DEVC) {len(devices)}")
+ lines.append(f" HRR quantities {n_hrr}")
+ lines.append(f" slices (SLCF) {len(fields_of(sim))}")
+ if starts:
+ lines.append(f" time {min(starts):g} … {max(ends):g} s")
+
+ groups = {}
+ for device in devices:
+ groups.setdefault(quantity_of(device) or "—", []).append(unit_of(device))
+ if groups:
+ lines += ["", " quantity # unit"]
+ for name, units in sorted(groups.items()):
+ shown = ", ".join(sorted(set(units) - {""})) or "—"
+ lines.append(f" {name:<28} {len(units):>4} {shown}")
+ return lines
+
+
+def list_contents(sim):
+ """Every slice and every curve, with the names the other options take."""
+ fields = fields_of(sim)
+ lines = ["slices:"]
+ if not fields:
+ lines.append(" (none)")
+ for i, field in enumerate(fields):
+ lines.append(
+ f" --slice {i:<3} {field.label} {len(field.times)} steps, t = {field.times[0]:g} … {field.times[-1]:g} s"
+ )
+
+ lines.append("")
+ lines.append("curves:")
+ series = build_series(sim)
+ if not series:
+ lines.append(" (none)")
+ for entry in series:
+ lines.append(f" --curve {entry['name']:<22} {entry['label']}")
+ return lines
+
+
+def render_slice(fields, state, height, width, ramp):
+ field = state.field(fields)
+ if field is None:
+ raise SystemExit(f"{PROG}: this simulation has no slice output")
+
+ timestep = state.clamp(fields)
+ frame = field.frame(timestep)
+ vmin, vmax, warning = state.limits(field, frame=frame)
+
+ lines = [f"{field.label} · t = {field.times[timestep]:.3f} s (step {timestep + 1} of {len(field.times)})", ""]
+ if warning:
+ lines.append(f" note: {warning}")
+ lines += slice_lines(field, timestep, vmin, vmax, height=height, width=width, ramp=ramp, frame=frame)
+ return lines
+
+
+def save_figure(fields, state, series, path, height, width):
+ """Write the current view to an image or an animation, using matplotlib."""
+ try:
+ import matplotlib
+
+ matplotlib.use("Agg")
+ import matplotlib.pyplot as plt
+ except ModuleNotFoundError:
+ raise SystemExit(f'{PROG}: --save needs matplotlib: pip install "fdsreader[notebook]"')
+ from .plots import plot_series, plot_slice
+
+ field = state.field(fields)
+ animated = path.suffix.lower() in (".gif", ".mp4")
+
+ if animated:
+ if field is None:
+ raise SystemExit(f"{PROG}: --save {path.suffix} needs a slice to animate")
+ from matplotlib import animation
+
+ steps = range(0, len(field.times), max(1, len(field.times) // 120))
+ fig, ax = plt.subplots()
+
+ def draw(step):
+ ax.clear()
+ frame = field.frame(step)
+ vmin, vmax, _ = state.limits(field, frame=frame)
+ plot_slice(
+ field,
+ step,
+ ax=ax,
+ vmin=vmin,
+ vmax=vmax,
+ cmap=state.colormap(field),
+ render=state.render,
+ n_levels=state.n_levels,
+ frame=frame,
+ )
+
+ writer = "pillow" if path.suffix.lower() == ".gif" else "ffmpeg"
+ anim = animation.FuncAnimation(fig, draw, frames=list(steps), interval=80)
+ try:
+ anim.save(str(path), writer=writer)
+ except Exception as exc:
+ raise SystemExit(f"{PROG}: could not write {path}: {exc}")
+ plt.close(fig)
+ return f"wrote {path} ({len(list(steps))} frames)"
+
+ panels = (1 if field is None else 1) + (1 if series else 0)
+ fig, axes = plt.subplots(1, max(1, panels), figsize=(7 * max(1, panels), 5.5))
+ axes = np.atleast_1d(axes)
+ at = 0
+ if field is not None:
+ timestep = state.clamp(fields)
+ frame = field.frame(timestep)
+ vmin, vmax, _ = state.limits(field, frame=frame)
+ plot_slice(
+ field,
+ timestep,
+ ax=axes[at],
+ vmin=vmin,
+ vmax=vmax,
+ cmap=state.colormap(field),
+ render=state.render,
+ n_levels=state.n_levels,
+ frame=frame,
+ )
+ at += 1
+ if series:
+ plot_series(series, ax=axes[at], cursor_time=state.time(fields) if field is not None else None)
+ fig.tight_layout()
+ fig.savefig(str(path), dpi=150)
+ plt.close(fig)
+ return f"wrote {path}"
+
+
+def as_json(sim, fields, state, series):
+ """The same information as the text output, for a script to consume."""
+ devices = list_devices(sim)
+ all_series = build_series(sim)
+ payload = {
+ "chid": sim.chid,
+ "path": sim.root_path,
+ "meshes": len(sim.meshes),
+ "devices": len(devices),
+ "hrr_quantities": sum(1 for e in all_series if e["source"] == "hrr"),
+ "slices": [
+ {
+ "index": i,
+ "label": f.label,
+ "quantity": f.quantity,
+ "unit": f.unit,
+ "shape": list(f.shape),
+ "axes": list(f.axes),
+ "n_timesteps": len(f.times),
+ "time": [float(f.times[0]), float(f.times[-1])],
+ "range": [float(f.value_range()[0]), float(f.value_range()[1])],
+ }
+ for i, f in enumerate(fields)
+ ],
+ "curves": [
+ {
+ "name": e["name"],
+ "label": e["label"],
+ "quantity": e["quantity"],
+ "unit": e["unit"],
+ "source": e["source"],
+ "n_samples": int(len(e["values"])),
+ "time": [float(e["times"][0]), float(e["times"][-1])],
+ "min": float(np.nanmin(e["values"])),
+ "max": float(np.nanmax(e["values"])),
+ "final": float(e["values"][-1]),
+ }
+ for e in all_series
+ ],
+ }
+ if series:
+ payload["selected"] = [
+ {"name": e["name"], "times": [float(v) for v in e["times"]], "values": [float(v) for v in e["values"]]}
+ for e in series
+ ]
+ return payload
+
+
+def resolve_curves(sim, names):
+ """Look up the requested curves by name."""
+ by_name = {}
+ for entry in build_series(sim):
+ by_name.setdefault(entry["name"], entry)
+ chosen = []
+ for name in names:
+ if name not in by_name:
+ raise SystemExit(f"{PROG}: no curve called {name!r} — try --list")
+ chosen.append(by_name[name])
+ return chosen
+
+
+def build_parser():
+ parser = argparse.ArgumentParser(
+ prog=PROG,
+ description="Look at FDS output in a terminal.",
+ epilog="With no options it prints what the simulation contains.",
+ )
+ parser.add_argument("path", help="simulation directory, or the .smv file")
+ parser.add_argument(
+ "-i",
+ "--interactive",
+ action="store_true",
+ help="explore the simulation in a full-screen terminal view, "
+ "choosing the slice and the curves from lists rather than "
+ "naming them here",
+ )
+ parser.add_argument("--list", action="store_true", help="list every slice and curve with the name to pass back in")
+ parser.add_argument("--slice", type=int, metavar="N", help="draw slice N")
+ parser.add_argument(
+ "--time", type=float, metavar="SECONDS", help="time step to draw, the nearest one is used (default: first)"
+ )
+ parser.add_argument(
+ "--scale",
+ default=None,
+ metavar="MODE",
+ help="colour limits: 'global', 'step' or 'LOW,HIGH'. Defaults to 'step' for a "
+ "single picture and 'global' interactively, where frames should stay comparable "
+ "while stepping through time",
+ )
+ parser.add_argument(
+ "--curve",
+ action="append",
+ default=[],
+ metavar="NAME",
+ help="draw this device or HRR quantity; repeat for several",
+ )
+ parser.add_argument(
+ "--height", type=int, default=26, metavar="ROWS", help="character rows for the slice (default: 26)"
+ )
+ parser.add_argument(
+ "--width", type=int, default=None, metavar="COLS", help="character columns (default: fit the terminal)"
+ )
+ parser.add_argument("--ramp", default=None, metavar="CHARS", help="characters from empty to full, e.g. ' .oO@'")
+ parser.add_argument("--json", action="store_true", help="print the same information as JSON, for scripting")
+ parser.add_argument(
+ "--save",
+ metavar="FILE",
+ help="write the current view to an image (.png/.pdf/.svg) or an "
+ "animation (.gif/.mp4) instead of drawing it as text; "
+ "needs matplotlib",
+ )
+ parser.add_argument(
+ "--render",
+ default="image",
+ choices=("image", "filled", "lines"),
+ help="how --save draws a slice (default: image)",
+ )
+ parser.add_argument(
+ "--caching",
+ action="store_true",
+ help="let fdsreader cache the parsed simulation as a .pickle "
+ "next to the data; off by default so that read-only "
+ "directories work",
+ )
+ return parser
+
+
+def main(argv=None):
+ """Entry point. Returns a process exit status."""
+ args = build_parser().parse_args(argv)
+
+ try:
+ sim = _load(args.path, caching=args.caching)
+ except Exception as exc:
+ print(f"{PROG}: could not load {args.path}: {exc}", file=sys.stderr)
+ return 1
+
+ fields = fields_of(sim)
+ if args.slice is not None and not 0 <= args.slice < len(fields):
+ print(
+ f"{PROG}: --slice {args.slice} out of range (0 … {len(fields) - 1})"
+ if fields
+ else f"{PROG}: this simulation has no slice output",
+ file=sys.stderr,
+ )
+ return 1
+
+ state = _state_from(args, fields)
+ state.render = args.render
+ series = resolve_curves(sim, args.curve)
+
+ if args.interactive:
+ from .tui import run as run_interactive
+
+ if series:
+ state.curves = tuple(range(len(series)))
+ try:
+ run_interactive(sim, state=state)
+ except ModuleNotFoundError as exc:
+ print(f"{PROG}: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+ if args.json:
+ json.dump(as_json(sim, fields, state, series), sys.stdout, indent=2)
+ sys.stdout.write("\n")
+ return 0
+
+ if args.save:
+ from pathlib import Path
+
+ print(save_figure(fields, state, series, Path(args.save), args.height, args.width))
+ return 0
+
+ lines = []
+ if args.list:
+ lines += list_contents(sim)
+ elif args.slice is None and not args.curve:
+ lines += overview(sim)
+
+ if args.slice is not None:
+ lines += render_slice(fields, state, args.height, args.width, args.ramp or RAMP)
+ if series:
+ if lines:
+ lines.append("")
+ lines += series_lines(series, height=max(6, args.height // 2), width=args.width, cursor_time=args.time)
+
+ print("\n".join(lines))
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/fdsreader/explorer/data.py b/fdsreader/explorer/data.py
new file mode 100644
index 0000000..1686311
--- /dev/null
+++ b/fdsreader/explorer/data.py
@@ -0,0 +1,230 @@
+"""Reading the parts of a simulation the explorer shows.
+
+Only depends on numpy, so it can be used without the plotting and widget extras.
+"""
+
+import csv
+from pathlib import Path
+
+import numpy as np
+
+TIME_DEVICE_ID = "Time"
+
+
+def list_devices(sim):
+ """All real devices of a simulation as a flat list.
+
+ The ``Time`` pseudo-device that fdsreader adds for the device time column is dropped.
+ Devices sharing an ID are stored as a list, and are flattened here.
+ """
+ devices = []
+ for entry in sim.devices:
+ group = entry if isinstance(entry, list) else [entry]
+ for device in group:
+ if device.id != TIME_DEVICE_ID:
+ devices.append(device)
+ return devices
+
+
+def load_device_data(devices):
+ """Force the device data to be read.
+
+ fdsreader reads the ``_devc.csv`` lazily and only fills in ``device.unit`` at that
+ point, so units would be missing from labels otherwise. Reading one device loads the
+ whole file, hence the single access.
+ """
+ for device in devices:
+ try:
+ device.data
+ except Exception:
+ pass
+ break
+
+
+def device_time(sim, device):
+ """Time axis in seconds matching ``device.data``."""
+ if TIME_DEVICE_ID in sim.devices:
+ times = np.asarray(sim.devices[TIME_DEVICE_ID].data)
+ if len(times) == len(device.data):
+ return times
+ # Fall back to the sample index if the simulation has no device time column.
+ return np.arange(len(device.data))
+
+
+def unit_of(device):
+ """Unit string, cleaned up: fdsreader keeps the trailing newline of the CSV header."""
+ return (device.unit or "").strip().strip('"')
+
+
+def quantity_of(device):
+ quantity = device.quantity
+ return (quantity.name if quantity is not None else "").strip()
+
+
+def device_label(device, duplicate_ids=()):
+ """Human-readable entry, e.g. ``TC_1 - TEMPERATURE [C]``.
+
+ Devices whose ID is not unique are additionally identified by their position.
+ """
+ parts = [device.id]
+ quantity = quantity_of(device)
+ if quantity:
+ parts.append(f"— {quantity}")
+ unit = unit_of(device)
+ if unit:
+ parts.append(f"[{unit}]")
+ if device.id in duplicate_ids:
+ x, y, z = device.position
+ parts.append(f"@ ({x:g}, {y:g}, {z:g})")
+ return " ".join(parts)
+
+
+def slice_label(slc, index=None):
+ """Entry for a slice, e.g. ``TEMPERATURE [C] - x = 0 m``.
+
+ Slices usually have an empty ``id`` -- FDS only names them when ``SLCF ID`` is given --
+ so they are identified by quantity and by the plane they cut.
+ """
+ quantity = slc.quantity.name.strip()
+ unit = (slc.quantity.unit or "").strip()
+ head = f"{quantity} [{unit}]" if unit else quantity
+ if slc.orientation in (1, 2, 3):
+ dim = "xyz"[slc.orientation - 1]
+ head += f" — {dim} = {slc.extent[dim][0]:g} m"
+ else:
+ head += " — 3D"
+ if slc.id.strip():
+ head += f" ({slc.id.strip()})"
+ elif index is not None:
+ head += f" #{index}"
+ return head
+
+
+def color_limits(values, diverging=None):
+ """Colour scale for an array: its real minimum and maximum.
+
+ ``diverging`` is decided once for a whole slice and handed back in for single frames,
+ so the scale cannot flip between centred and one-sided while stepping through time.
+ """
+ low, high = float(np.nanmin(values)), float(np.nanmax(values))
+ if not np.isfinite(low) or not np.isfinite(high):
+ low, high = 0.0, 1.0
+ if diverging is None:
+ diverging = low < 0 < high
+ if diverging: # velocities read better on a scale centred at zero
+ bound = max(abs(low), abs(high)) or 1.0
+ low, high = -bound, bound
+ if low == high: # a frame of constant value still needs a finite range
+ low, high = low - 0.5, high + 0.5
+ return low, high, diverging
+
+
+def prepare_slice(slc):
+ """Read a slice into one global array and work out how to draw it.
+
+ ``to_global`` stitches the per-mesh subslices together and returns the data with the
+ slice-normal axis squeezed out, so the array is ``(n_t, dim1, dim2)`` with dim1/dim2 in
+ x, y, z order. Returns ``None`` for anything that is not a drawable 2D slice.
+ """
+ data, coords = slc.to_global(return_coordinates=True)
+
+ # A cell-centered slice sitting exactly on a mesh border has a valid representation on
+ # either side, and fdsreader hands back both. The first one is used here.
+ if isinstance(data, tuple):
+ data, coords = data[0], coords[0]
+
+ spanned = [d for d in ("x", "y", "z") if len(coords[d]) > 1]
+ if data.size == 0 or len(spanned) != 2:
+ return None
+ horizontal, vertical = spanned # x, y, z order puts the upright axis second
+
+ low, high, diverging = color_limits(data)
+ return {
+ "slice": slc,
+ "data": data,
+ "coords": coords,
+ "axes": (horizontal, vertical),
+ "vmin": low,
+ "vmax": high,
+ "diverging": diverging,
+ "cmap": "RdBu_r" if diverging else "inferno",
+ }
+
+
+def hrr_units(sim):
+ """Units of the HRR columns.
+
+ ``sim.hrr`` is a plain dict of name -> values; fdsreader skips the unit line of the
+ ``_hrr.csv`` header when reading it, so it is read again here.
+ """
+ for path in sorted(Path(sim.root_path).glob("*_hrr.csv")):
+ try:
+ with open(path) as infile:
+ units = next(csv.reader([infile.readline()]))
+ names = next(csv.reader([infile.readline()]))
+ except (OSError, StopIteration):
+ return {}
+ return {n.strip(): u.strip() for n, u in zip(names, units)}
+ return {}
+
+
+def build_series(sim):
+ """Every plottable time series of a simulation: the devices and the HRR quantities.
+
+ Both are reduced to the same shape so they can be picked from one list and drawn on one
+ figure. Each series keeps its own time axis, because the HRR file and the device file
+ are not necessarily written at the same interval.
+ """
+ series = []
+
+ devices = list_devices(sim)
+ load_device_data(devices) # so that units are known when building the labels
+ seen, duplicate_ids = set(), set()
+ for device in devices:
+ (duplicate_ids if device.id in seen else seen).add(device.id)
+
+ for device in devices:
+ series.append(
+ {
+ "label": "DEVC " + device_label(device, duplicate_ids),
+ "name": device.id,
+ "times": device_time(sim, device),
+ "values": np.asarray(device.data),
+ "quantity": quantity_of(device),
+ "unit": unit_of(device),
+ "source": "device",
+ "device": device,
+ }
+ )
+
+ # `sim.hrr` only exists when the simulation wrote an HRR file.
+ hrr = getattr(sim, "hrr", None)
+ if hrr and "Time" in hrr:
+ units = hrr_units(sim)
+ times = np.asarray(hrr["Time"])
+ for name, values in hrr.items():
+ if name == "Time":
+ continue
+ unit = units.get(name, "").strip()
+ series.append(
+ {
+ "label": f"HRR {name}" + (f" [{unit}]" if unit else ""),
+ "name": name,
+ "times": times,
+ "values": np.asarray(values),
+ "quantity": name,
+ "unit": unit,
+ "source": "hrr",
+ "device": None,
+ }
+ )
+
+ return series
+
+
+def axis_label(group):
+ """Y-axis label for a group of series sharing an axis."""
+ quantities = {s["quantity"] for s in group}
+ units = sorted({s["unit"] for s in group if s["unit"]})
+ head = quantities.pop() if len(quantities) == 1 else "Value"
+ return f"{head} [{', '.join(units)}]" if units else head
diff --git a/fdsreader/explorer/fields.py b/fdsreader/explorer/fields.py
new file mode 100644
index 0000000..e565ead
--- /dev/null
+++ b/fdsreader/explorer/fields.py
@@ -0,0 +1,197 @@
+"""A 2D field that can be drawn at one instant in time.
+
+Every front end asks a field the same three things -- what are your coordinates, what did
+you look like at time step *t*, and what is your value range -- so slices, and later
+boundary data or a plane cut from 3D output, can all be drawn by the same code.
+
+Frames are read one at a time. A slice file is a sequence of fixed-size records, so the
+frame wanted can be seeked to directly instead of materialising the whole time series,
+which for a production run would be gigabytes.
+"""
+
+import os
+
+import numpy as np
+
+import fdsreader.utils.fortran_data as fdtype
+
+from .data import color_limits, slice_label
+
+
+def _read_subslice_frame(subslice, timestep):
+ """One time step of one sub-slice, without reading the rest of the file.
+
+ Mirrors what fdsreader does when it loads the whole array, but seeks to a single
+ record. Belongs in :mod:`fdsreader.slcf` as ``SubSlice.frame(t)`` eventually.
+ """
+ count = subslice.dimension.size(cell_centered=False)
+ record = fdtype.combine(fdtype.FLOAT, fdtype.new((("f", count),)))
+ path = os.path.join(subslice._parent_slice._root_path, subslice.filename)
+
+ with open(path, "rb") as infile:
+ infile.seek(subslice._offset + timestep * record.itemsize)
+ data = fdtype.read(infile, record, 1)[0]
+
+ frame = data[1].reshape(subslice.dimension.shape(cell_centered=False), order="F")
+ if subslice.cell_centered:
+ # Ignore ghost points on every axis, as the eager path does.
+ frame = frame[(slice(1, None),) * frame.ndim]
+ return frame
+
+
+def _bnd_range(slc):
+ """Value range of a slice from the ``.bnd`` files FDS writes beside the slice files.
+
+ Saves reading any data at all for the global colour scale. The range covers every
+ sub-slice, so where a cell-centered slice straddles a mesh border it can be slightly
+ wider than the half that ends up on screen. Returns ``None`` when a file is missing.
+ """
+ low, high = np.inf, -np.inf
+ for subslice in slc.subslices:
+ path = os.path.join(slc._root_path, subslice.filename + ".bnd")
+ if not os.path.exists(path):
+ return None
+ try:
+ with open(path) as infile:
+ for line in infile:
+ parts = line.split()
+ if len(parts) >= 3:
+ low = min(low, float(parts[1]))
+ high = max(high, float(parts[2]))
+ except (OSError, ValueError):
+ return None
+ return (float(low), float(high)) if np.isfinite(low) and np.isfinite(high) else None
+
+
+def _dedup(values):
+ """Sort and drop near-duplicates: FDS writes coordinates with limited precision."""
+ values = np.sort(np.asarray(values, dtype=float))
+ if values.size:
+ values = values[np.concatenate(([True], np.diff(values) > 2e-6))]
+ return values
+
+
+def _sides(slc, normal):
+ """Sub-slices grouped by where they sit along the slice normal.
+
+ A cell-centered slice lying exactly on a mesh border has no cells *at* the border, so
+ FDS writes the cells on either side of it. Each side is a complete, equally valid
+ picture of the slice; the eager path returns both and this keeps them apart.
+ """
+ groups = {}
+ for subslice in slc.subslices:
+ coord = subslice.get_coordinates()[normal]
+ if not len(coord):
+ continue
+ groups.setdefault(round(float(coord[0]), 6), []).append(subslice)
+ return [groups[key] for key in sorted(groups)]
+
+
+class SliceField:
+ """A 2D ``SLCF`` slice, stitched across meshes one time step at a time."""
+
+ kind = "slice"
+
+ def __init__(self, slc, index=None, side=0):
+ self.source = slc
+ self.index = index
+ self.label = slice_label(slc, index)
+ self.quantity = slc.quantity.name.strip()
+ self.unit = (slc.quantity.unit or "").strip()
+ self.times = np.asarray(slc.times, dtype=float)
+
+ self.drawable = slc.orientation in (1, 2, 3)
+ if not self.drawable: # a 3D slice has no single plane to draw
+ self.axes, self.coords, self._placement, self.shape = (), {}, [], (0, 0)
+ return
+
+ normal = "xyz"[slc.orientation - 1]
+ horizontal, vertical = [d for d in ("x", "y", "z") if d != normal]
+ self.axes = (horizontal, vertical) # x, y, z order puts the upright axis second
+
+ sides = _sides(slc, normal)
+ subslices = sides[min(side, len(sides) - 1)] if sides else []
+
+ # Cell coordinates decide where each sub-slice goes in the stitched grid ...
+ cell = (
+ {d: _dedup(np.concatenate([sub.get_coordinates()[d] for sub in subslices])) for d in (horizontal, vertical)}
+ if subslices
+ else {horizontal: np.array([]), vertical: np.array([])}
+ )
+ self.shape = (len(cell[horizontal]), len(cell[vertical]))
+
+ # ... while the drawn axes span the extent the slice actually covers, so that a
+ # cell-centered field is not drawn half a cell adrift.
+ self.coords = {
+ normal: np.array([float(slc.extent[normal][0])]),
+ horizontal: np.linspace(*slc.extent[horizontal], self.shape[0]),
+ vertical: np.linspace(*slc.extent[vertical], self.shape[1]),
+ }
+
+ self._placement = []
+ for subslice in subslices:
+ sub_coords = subslice.get_coordinates()
+ sub_h, sub_v = sub_coords[horizontal], sub_coords[vertical]
+ if not len(sub_h) or not len(sub_v):
+ continue
+ i0 = int(np.argmin(np.abs(cell[horizontal] - sub_h[0])))
+ j0 = int(np.argmin(np.abs(cell[vertical] - sub_v[0])))
+ self._placement.append((subslice, i0, len(sub_h), j0, len(sub_v)))
+
+ self.drawable = self.shape[0] > 1 and self.shape[1] > 1
+ self._range = None
+
+ # -- data ------------------------------------------------------------
+ def frame(self, timestep):
+ """The stitched field at one time step, as ``(n_horizontal, n_vertical)``."""
+ out = np.full(self.shape, np.nan, dtype=np.float32)
+ written = np.zeros(self.shape, dtype=bool)
+
+ for subslice, i0, ni, j0, nj in self._placement:
+ data = _read_subslice_frame(subslice, timestep)
+ i1, j1 = min(i0 + ni, self.shape[0]), min(j0 + nj, self.shape[1])
+ patch = data[: i1 - i0, : j1 - j0]
+ region = (slice(i0, i1), slice(j0, j1))
+ # Where a cell-centered slice straddles a mesh border two sub-slices claim the
+ # same cells; the first one wins, the way the eager path keeps one of them.
+ out[region] = np.where(written[region], out[region], patch)
+ written[region] = True
+ return out
+
+ def value_range(self):
+ """``(vmin, vmax, diverging)`` over the whole time series.
+
+ Read from the ``.bnd`` files when FDS wrote them, so no field data is touched.
+ """
+ if self._range is None:
+ bounds = _bnd_range(self.source)
+ if bounds is None: # no .bnd files: fall back to scanning the frames
+ low, high = np.inf, -np.inf
+ for t in range(len(self.times)):
+ frame = self.frame(t)
+ low = min(low, float(np.nanmin(frame)))
+ high = max(high, float(np.nanmax(frame)))
+ bounds = (low, high)
+ self._range = color_limits(np.asarray(bounds, dtype=float))
+ return self._range
+
+ @property
+ def cmap(self):
+ return "RdBu_r" if self.value_range()[2] else "inferno"
+
+ def clear_cache(self):
+ self.source.clear_cache()
+
+
+def fields_of(sim):
+ """Every drawable 2D field of a simulation.
+
+ Slices today. Boundary data and planes cut from 3D output would be appended here, and
+ every front end would pick them up without changing.
+ """
+ fields = []
+ for i, slc in enumerate(sim.slices):
+ field = SliceField(slc, i)
+ if field.drawable:
+ fields.append(field)
+ return fields
diff --git a/fdsreader/explorer/notebook.py b/fdsreader/explorer/notebook.py
new file mode 100644
index 0000000..88dfc7b
--- /dev/null
+++ b/fdsreader/explorer/notebook.py
@@ -0,0 +1,731 @@
+"""Jupyter front end: a directory browser, a time bar, a slice and a curve plot.
+
+Importing this module requires the notebook extras::
+
+ pip install "fdsreader[notebook]"
+"""
+
+try:
+ import ipywidgets as widgets
+except ModuleNotFoundError as exc: # pragma: no cover - depends on the installation
+ raise ModuleNotFoundError(
+ "fdsreader.explorer.notebook needs ipywidgets and matplotlib. Install them with:\n"
+ ' pip install "fdsreader[notebook]"\n'
+ "ipympl is optional and adds zooming and panning."
+ ) from exc
+
+from contextlib import contextmanager
+from importlib.util import find_spec
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+from IPython.display import display
+
+import fdsreader
+from fdsreader import settings
+
+from .data import build_series, list_devices, quantity_of, unit_of
+from .fields import fields_of
+from .plots import DIVERGING_CMAPS, SEQUENTIAL_CMAPS, plot_series, plot_slice
+from .state import ExplorerState
+
+
+def _select_backend(preference=None):
+ """Switch matplotlib to ipympl when it is usable, otherwise to inline images.
+
+ ``preference`` is ``None`` to auto-detect, ``True`` to insist on ipympl and ``False``
+ to force inline. Auto-detection cannot see whether the *browser* has the matching
+ front-end extension, so pass ``False`` when the plots come up as
+ "Failed to load model class 'MPLCanvasModel'".
+ """
+ try:
+ from IPython import get_ipython
+
+ shell = get_ipython()
+ except Exception:
+ shell = None
+
+ if preference is not False:
+ # Probe without importing: importing ipympl switches the matplotlib backend as a
+ # side effect, and that would stick even when the inline fall-back is taken.
+ available = find_spec("ipympl") is not None
+ if available and shell is not None:
+ try:
+ shell.run_line_magic("matplotlib", "widget")
+ return True
+ except Exception:
+ if preference is True:
+ raise
+ elif preference is True:
+ raise RuntimeError(
+ "interactive_canvas=True, but "
+ + ("ipympl is not installed" if not available else "no IPython shell is running")
+ )
+
+ if shell is not None:
+ try:
+ shell.run_line_magic("matplotlib", "inline")
+ except Exception:
+ pass
+ return False
+
+
+@contextmanager
+def _figure_surface(fig, view, figsize):
+ """Hand out a blank figure to draw on, whichever backend is in use.
+
+ With ipympl the one long-lived figure is cleared and redrawn in place, because its
+ canvas is a widget that already sits in the layout. Without it, a throw-away figure is
+ rendered into an ``Output`` widget and closed again -- inline figures made inside a
+ widget callback are not cleaned up on their own and would otherwise pile up.
+ """
+ if fig is not None:
+ fig.clear()
+ yield fig
+ fig.canvas.draw_idle()
+ else:
+ view.clear_output(wait=True)
+ with view:
+ throwaway = plt.figure(figsize=figsize)
+ try:
+ yield throwaway
+ plt.show()
+ finally:
+ plt.close(throwaway)
+
+
+def _clear_surface(fig, view):
+ """Blank the plot area without drawing anything into it."""
+ if fig is not None:
+ fig.clear()
+ fig.canvas.draw_idle()
+ else:
+ view.clear_output()
+
+
+class SimulationBrowser:
+ """Directory browser that loads the FDS simulation found in the selected folder."""
+
+ def __init__(self, start_dir=None, on_load=None):
+ self.on_load = on_load
+ self.sim = None
+ self.current = None
+
+ self.path_box = widgets.Text(
+ description="Path:",
+ continuous_update=False,
+ layout=widgets.Layout(flex="1 1 auto", width="auto", min_width="240px"),
+ style={"description_width": "50px"},
+ )
+ self.up_button = widgets.Button(description="⬆ Up", layout=widgets.Layout(width="90px"))
+ self.folders = widgets.Select(
+ options=[],
+ rows=10,
+ layout=widgets.Layout(width="auto", min_width="220px"),
+ )
+ self.smv_picker = widgets.Dropdown(
+ description=".smv:",
+ layout=widgets.Layout(width="340px", display="none"),
+ style={"description_width": "50px"},
+ )
+ self.load_button = widgets.Button(
+ description="Load simulation",
+ button_style="primary",
+ disabled=True,
+ layout=widgets.Layout(width="160px"),
+ )
+ self.status = widgets.HTML()
+
+ self.up_button.on_click(lambda _: self.go_to(self.current.parent))
+ self.load_button.on_click(lambda _: self.load())
+ self.folders.observe(self._on_folder_selected, names="value")
+ self.path_box.observe(self._on_path_typed, names="value")
+
+ # The folder list and the status share a row so the browser stays shallow and
+ # leaves the vertical space to the plots.
+ self.widget = widgets.VBox(
+ [
+ widgets.HBox([self.path_box], layout=widgets.Layout(width="100%")),
+ widgets.HBox(
+ [self.up_button, self.load_button, self.smv_picker],
+ layout=widgets.Layout(flex_flow="row wrap", align_items="center"),
+ ),
+ widgets.HBox(
+ [
+ widgets.Box([self.folders], layout=widgets.Layout(flex="1 1 280px", min_width="240px")),
+ widgets.Box(
+ [self.status],
+ layout=widgets.Layout(flex="1 1 300px", min_width="240px", padding="4px 0 0 14px"),
+ ),
+ ],
+ layout=widgets.Layout(width="100%", flex_flow="row wrap", align_items="flex-start"),
+ ),
+ ],
+ layout=widgets.Layout(width="100%"),
+ )
+
+ self.go_to(Path(start_dir) if start_dir is not None else Path.cwd())
+
+ # -- navigation ------------------------------------------------------
+ def _on_path_typed(self, change):
+ if self.current is not None and change["new"] != str(self.current):
+ self.go_to(change["new"])
+
+ def _on_folder_selected(self, change):
+ if change["new"]:
+ self.go_to(Path(change["new"]))
+
+ def go_to(self, directory):
+ directory = Path(directory).expanduser()
+ # A typed relative path should be relative to the folder on screen, not to the
+ # directory the Jupyter kernel happens to have been started in.
+ if not directory.is_absolute() and self.current is not None:
+ directory = self.current / directory
+ if not directory.is_dir():
+ self.status.value = f"Not a directory: {directory}"
+ return
+
+ self.current = directory.resolve()
+ self.path_box.value = str(self.current)
+
+ try:
+ subdirs = sorted(
+ (p for p in self.current.iterdir() if p.is_dir() and not p.name.startswith(".")),
+ key=lambda p: p.name.lower(),
+ )
+ except PermissionError:
+ subdirs = []
+ self.status.value = "Permission denied."
+
+ # Rebuild the list without re-triggering navigation.
+ self.folders.unobserve(self._on_folder_selected, names="value")
+ self.folders.options = [(p.name + "/", str(p)) for p in subdirs]
+ self.folders.value = None
+ self.folders.observe(self._on_folder_selected, names="value")
+
+ self._scan_for_smv()
+
+ def _scan_for_smv(self):
+ smv_files = sorted(self.current.glob("*.smv"))
+
+ if not smv_files:
+ self.smv_picker.layout.display = "none"
+ self.load_button.disabled = True
+ self.status.value = (
+ f"No .smv file in "
+ f"{self.current.name or self.current} — keep browsing."
+ )
+ return
+
+ self.load_button.disabled = False
+ self.smv_picker.options = [(p.name, str(p)) for p in smv_files]
+ # Assigning `options` does not select anything while the index is None.
+ self.smv_picker.index = 0
+ if len(smv_files) > 1:
+ self.smv_picker.layout.display = "flex"
+ self.status.value = (
+ f"{len(smv_files)} simulations found — choose one, then click Load simulation."
+ )
+ else:
+ self.smv_picker.layout.display = "none"
+ self.status.value = f"Found {smv_files[0].name} — click Load simulation."
+
+ # -- loading ---------------------------------------------------------
+ def load(self):
+ smv_path = self.smv_picker.value
+ if smv_path is None or Path(smv_path).parent != self.current:
+ self.status.value = "No simulation selected in this folder."
+ return
+
+ self.status.value = f"Loading {Path(smv_path).name} …"
+ try:
+ self.sim = fdsreader.Simulation(smv_path)
+ except Exception as exc:
+ self.sim = None
+ self.status.value = f"Could not load: {exc}"
+ return
+
+ devices = list_devices(self.sim)
+ self.status.value = (
+ f"Loaded {self.sim.chid} — "
+ f"{len(devices)} device(s), {len(self.sim.meshes)} mesh(es)."
+ )
+ if self.on_load is not None:
+ self.on_load(self.sim)
+
+
+class NotebookExplorer:
+ """Interactive view of one FDS simulation at a time.
+
+ Shows a directory browser, a time bar, a 2D slice and any number of device or HRR
+ curves. The slice and the curves sit side by side on a wide window and wrap onto
+ their own lines on a narrow one.
+
+ :param path: Simulation to load straight away; if omitted, browse to one.
+ :param start_dir: Where the directory browser opens. Defaults to the working directory.
+ :param interactive_canvas: ``None`` to use ipympl when available, ``True`` to insist,
+ ``False`` to force static inline images. Pass ``False`` if the plots come up as
+ "Failed to load model class 'MPLCanvasModel'", which means the browser-side
+ extension is missing.
+ :param caching: Sets :data:`fdsreader.settings.ENABLE_CACHING`. Off by default:
+ fdsreader caches a parsed simulation as a ``.pickle`` next to the data, which
+ fails on a read-only directory such as a shared course folder. Note that such a
+ directory must also be free of leftover ``.pickle`` files, since switching the
+ cache off makes fdsreader try to delete them.
+ """
+
+ def __init__(
+ self,
+ path=None,
+ start_dir=None,
+ interactive_canvas=None,
+ caching=False,
+ slice_figsize=(6.5, 5.5),
+ curve_figsize=(7.0, 5.5),
+ panel_basis="560px",
+ panel_min="360px",
+ ):
+ settings.ENABLE_CACHING = caching
+ self.interactive_canvas = _select_backend(interactive_canvas)
+ self.slice_figsize = slice_figsize
+ self.curve_figsize = curve_figsize
+
+ self.sim = None
+ self.series = []
+ self.devices = []
+ self.fields = []
+ self.state = ExplorerState()
+ self._updating = False
+
+ self._build_widgets(panel_basis, panel_min)
+ if start_dir is None and path is not None:
+ start_dir = Path(path).expanduser().parent if Path(path).is_file() else path
+ self.browser = SimulationBrowser(start_dir=start_dir, on_load=self._on_simulation_loaded)
+
+ self.widget = widgets.VBox(
+ [
+ self.browser.widget,
+ self._rule(),
+ self.summary,
+ self._rule(),
+ self.time_bar,
+ self._rule(),
+ widgets.HBox(
+ [self.slice_panel, self.curve_panel],
+ layout=widgets.Layout(width="100%", flex_flow="row wrap", align_items="flex-start"),
+ ),
+ ],
+ layout=widgets.Layout(width="100%"),
+ )
+
+ if path is not None:
+ self.load(path)
+
+ # -- construction ----------------------------------------------------
+ @staticmethod
+ def _rule():
+ return widgets.HTML("
t = {field.times[timestep]:.2f} s "
+ f"(step {timestep + 1} of {len(field.times)})"
+ )
+
+ frame = field.frame(timestep) # read once, drawn and possibly scaled from
+ vmin, vmax, warning = self.state.limits(field, frame=frame)
+ self.scale_note.value = f"{warning}" if warning else ""
+
+ with _figure_surface(self.slice_fig, self.slice_view, self.slice_figsize) as fig:
+ ax = fig.add_subplot(111)
+ plot_slice(
+ field,
+ timestep,
+ ax=ax,
+ vmin=vmin,
+ vmax=vmax,
+ cmap=self.state.colormap(field),
+ render=self.state.render,
+ n_levels=self.state.n_levels,
+ frame=frame,
+ )
+ fig.tight_layout()
+
+ def _refresh_curves(self, _change=None):
+ if self._updating:
+ return
+ self._sync_state()
+ selected = self.selection
+ if not selected:
+ _clear_surface(self.curve_fig, self.curve_view)
+ return
+ with _figure_surface(self.curve_fig, self.curve_view, self.curve_figsize) as fig:
+ ax = fig.add_subplot(111)
+ plot_series(selected, ax=ax, cursor_time=self.current_time)
+ fig.tight_layout()
+
+ # -- callbacks -------------------------------------------------------
+ def _on_time_change(self, _change=None):
+ """The time bar moves the slice and the marker on the curve plot together."""
+ if self._updating:
+ return
+ self._refresh_slice()
+ self._refresh_curves()
+
+ def _on_render_change(self, _change=None):
+ """The level count only matters for the contour modes."""
+ self.level_slider.layout.display = "none" if self.render_mode.value == "image" else "flex"
+ self._refresh_slice()
+
+ def _on_scale_mode_change(self, _change=None):
+ manual = self.scale_mode.value == "manual"
+ self.scale_manual.layout.display = "flex" if manual else "none"
+ field = self.field
+ if manual and field is not None and not self._updating:
+ # Start from what is on screen, so the fields are a useful starting point.
+ self._sync_state()
+ self.state.scale_mode = "global"
+ low, high, _ = self.state.limits(field)
+ self._updating = True
+ self.scale_min.value, self.scale_max.value = round(low, 4), round(high, 4)
+ self._updating = False
+ self._refresh_slice()
+
+ def _on_slice_change(self, _change=None):
+ if self._updating:
+ return
+ index = self.slice_dropdown.value
+ if index is None:
+ return
+
+ previous = self.field
+ if previous is not None:
+ previous.clear_cache() # keep only one field's data in memory
+
+ self.state.field_index = index
+ field = self.field
+ if field is None:
+ self.slice_note.value = "Nothing to show."
+ _clear_surface(self.slice_fig, self.slice_view)
+ return
+ self.slice_note.value = ""
+
+ self._updating = True
+ last = len(field.times) - 1
+ self.time_slider.max = self.time_play.max = last
+ self.time_slider.value = self.time_play.value = min(self.time_slider.value, last)
+ self.time_slider.disabled = self.time_play.disabled = False
+ self._updating = False
+
+ self._on_time_change()
+
+ # -- loading ---------------------------------------------------------
+ def _setup_fields(self, sim):
+ """Fill the field dropdown and the time bar for a freshly loaded simulation."""
+ self.fields = fields_of(sim)
+ self.state.field_index = 0
+ self.state.timestep = 0
+
+ self._updating = True
+ self.slice_dropdown.options = [(f.label, i) for i, f in enumerate(self.fields)]
+ self.slice_dropdown.disabled = not self.fields
+ self.time_slider.disabled = self.time_play.disabled = not self.fields
+ if not self.fields:
+ self.time_slider.max = self.time_play.max = 0
+ self._updating = False
+
+ _clear_surface(self.slice_fig, self.slice_view)
+ if not self.fields:
+ self.slice_note.value = ""
+ self.time_readout.value = "This simulation has no slice (SLCF) output."
+ return
+
+ self._updating = True
+ self.slice_dropdown.index = 0
+ self._updating = False
+ self._on_slice_change()
+
+ def _on_simulation_loaded(self, sim):
+ """Populate the curve list, the slices and the overview once a simulation is read."""
+ self.sim = sim
+ self.devices = list_devices(sim)
+ self.series = build_series(sim)
+ self.state.curves = (0,) if self.series else ()
+
+ self._updating = True
+ self.series_select.options = [(e["label"], i) for i, e in enumerate(self.series)]
+ self.series_select.disabled = not self.series
+ self.series_select.value = (0,) if self.series else ()
+ self._updating = False
+
+ if not self.series:
+ self.summary.value = "This simulation has no device (DEVC) or HRR output."
+ _clear_surface(self.curve_fig, self.curve_view)
+ self._setup_fields(sim)
+ return
+
+ self.summary.value = self._overview()
+ self._setup_fields(sim) # also positions the time bar
+ self._refresh_curves()
+
+ def _overview(self):
+ """Short HTML summary: what is in this simulation, at a glance."""
+ n_hrr = sum(1 for e in self.series if e["source"] == "hrr")
+ counts = []
+ if self.devices:
+ counts.append(f"{len(self.devices)} devices")
+ if n_hrr:
+ counts.append(f"{n_hrr} HRR quantities")
+ if self.fields:
+ counts.append(f"{len(self.fields)} slices")
+
+ # Devices and the HRR file are not necessarily written at the same interval, so
+ # report the span covered by everything rather than one series' sample count.
+ starts = [e["times"][0] for e in self.series if len(e["times"])]
+ ends = [e["times"][-1] for e in self.series if len(e["times"])]
+ if starts:
+ counts.append(f"t = {min(starts):g} … {max(ends):g} s")
+
+ groups = {}
+ for device in self.devices:
+ groups.setdefault(quantity_of(device) or "—", []).append(unit_of(device))
+ if n_hrr:
+ groups["HRR file"] = [e["unit"] for e in self.series if e["source"] == "hrr"]
+
+ rows = "".join(
+ f"| Quantity | +# | Unit | +
|---|