ipyrowtable 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ """ipyrowtable: editable ipywidgets tables of elements in series.
2
+
3
+ Each row is an element (a layer, a pipe segment, a cable run, ...) with input columns the
4
+ user edits and output columns your compute function fills in. Rows can be added and
5
+ removed, values *between* rows (temperatures at interfaces, pressures at joints) get their
6
+ own column with editable boundary conditions, and an optional toggle switches unit systems.
7
+
8
+ from ipyrowtable import RowTable, NumberColumn, OutputColumn
9
+
10
+ table = RowTable(
11
+ columns=[NumberColumn("x", "x", default=1.0), OutputColumn("x2", "x²")],
12
+ compute=lambda inputs: {"x2": inputs.column("x") ** 2},
13
+ )
14
+ table
15
+
16
+ See the example notebooks, and ipyrowtable.examples.conduction for a complete application.
17
+ """
18
+
19
+ from .columns import (
20
+ ChoiceColumn,
21
+ Column,
22
+ InputColumn,
23
+ NodeColumn,
24
+ NumberColumn,
25
+ OutputColumn,
26
+ TextColumn,
27
+ )
28
+ from .figure import LiveFigure
29
+ from .formatting import fmt_sig
30
+ from .layout import side_by_side
31
+ from .table import RowTable, TableInputs, TableResults
32
+ from .units import Unit, UnitSystem
33
+
34
+ __version__ = "0.1.1"
35
+
36
+ __all__ = [
37
+ "RowTable",
38
+ "TableInputs",
39
+ "TableResults",
40
+ "Column",
41
+ "InputColumn",
42
+ "ChoiceColumn",
43
+ "NumberColumn",
44
+ "TextColumn",
45
+ "OutputColumn",
46
+ "NodeColumn",
47
+ "Unit",
48
+ "UnitSystem",
49
+ "LiveFigure",
50
+ "side_by_side",
51
+ "fmt_sig",
52
+ "__version__",
53
+ ]
ipyrowtable/columns.py ADDED
@@ -0,0 +1,265 @@
1
+ """Column types for RowTable.
2
+
3
+ Input columns (edited by the user): ChoiceColumn, NumberColumn, TextColumn
4
+ Output columns (filled by compute): OutputColumn, NodeColumn
5
+
6
+ Input columns can also be used as table-wide *parameters* (shown above the table).
7
+ To make a new input type, subclass InputColumn and implement ``create_widget``; override
8
+ ``to_display``, ``to_base`` and ``refresh`` if the widget shows something other than the
9
+ stored value.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+
16
+ import ipywidgets as widgets
17
+
18
+ from .formatting import formatter, right_aligned, tidy
19
+
20
+ __all__ = [
21
+ "Column",
22
+ "InputColumn",
23
+ "NumberColumn",
24
+ "ChoiceColumn",
25
+ "TextColumn",
26
+ "OutputColumn",
27
+ "NodeColumn",
28
+ ]
29
+
30
+
31
+ class _UnitLabels(dict):
32
+ """format_map helper: {unit} is the column's own unit, {<quantity>} any quantity's unit."""
33
+
34
+ def __init__(self, units, quantity):
35
+ super().__init__()
36
+ self.units, self.quantity = units, quantity
37
+
38
+ def __missing__(self, key):
39
+ return self.units.label(self.quantity if key == "unit" else key)
40
+
41
+
42
+ class Column:
43
+ """Base class for all columns.
44
+
45
+ key identifies the column in rows, compute outputs and results
46
+ header string shown at the top; may use {unit} for this column's unit or {<quantity>}
47
+ for any other quantity's unit, e.g. "Material (k in {conductivity})".
48
+ Can also be a callable(units) -> str.
49
+ width CSS width of the grid column
50
+ """
51
+
52
+ width = "120px"
53
+ quantity = None
54
+
55
+ def __init__(self, key: str, header=None, width: str | None = None):
56
+ self.key = key
57
+ self.header = key if header is None else header
58
+ if width is not None:
59
+ self.width = width
60
+
61
+ def header_text(self, units) -> str:
62
+ if callable(self.header):
63
+ return self.header(units)
64
+ return self.header.format_map(_UnitLabels(units, self.quantity))
65
+
66
+ def __repr__(self):
67
+ return f"{type(self).__name__}({self.key!r})"
68
+
69
+
70
+ class InputColumn(Column):
71
+ """Base for editable columns and parameters. Values are held in base units.
72
+
73
+ default value for new rows, in base units; or a dict {unit-system name: display value}
74
+ for defaults that are round numbers in each system.
75
+ """
76
+
77
+ def __init__(self, key, header=None, default=None, width=None):
78
+ super().__init__(key, header, width)
79
+ self.default = default
80
+
81
+ def default_value(self, units):
82
+ """The default in base units, for the given unit system."""
83
+ if isinstance(self.default, dict):
84
+ return self.to_base(self.default[units.name], units)
85
+ return self.default
86
+
87
+ def to_display(self, value, units):
88
+ """Convert a stored (base) value to what the widget shows."""
89
+ return value
90
+
91
+ def to_base(self, value, units):
92
+ """Convert a widget value to the stored (base) value."""
93
+ return value
94
+
95
+ def create_widget(self, value, units):
96
+ """Return a new widget showing `value` (base units). Must have a `value` trait."""
97
+ raise NotImplementedError
98
+
99
+ def refresh(self, widget, value, units):
100
+ """Show `value` (base units) in `widget` after a unit-system change."""
101
+ widget.value = self.to_display(value, units)
102
+
103
+ def validate(self, value):
104
+ """Check a saved value (base units) before it is restored; return it, possibly
105
+ normalized, or raise ValueError. The default accepts anything."""
106
+ return value
107
+
108
+
109
+ class NumberColumn(InputColumn):
110
+ """A number per row.
111
+
112
+ quantity name of the quantity in your UnitSystems (e.g. "length"); None = unitless
113
+ min, max bounds in base units; if either is given the box clamps to them
114
+ """
115
+
116
+ def __init__(self, key, header=None, quantity=None, default=0.0, min=None, max=None,
117
+ width=None):
118
+ super().__init__(key, header, default, width)
119
+ self.quantity, self.min, self.max = quantity, min, max
120
+
121
+ def to_display(self, value, units):
122
+ return tidy(units.to_display(self.quantity, value))
123
+
124
+ def to_base(self, value, units):
125
+ return float(units.to_base(self.quantity, value))
126
+
127
+ def _bounds(self, units):
128
+ lo = -1e300 if self.min is None else units.to_display(self.quantity, self.min)
129
+ hi = 1e300 if self.max is None else units.to_display(self.quantity, self.max)
130
+ return lo, hi
131
+
132
+ def create_widget(self, value, units):
133
+ layout = widgets.Layout(width="100%")
134
+ if self.min is None and self.max is None:
135
+ return widgets.FloatText(value=self.to_display(value, units), layout=layout)
136
+ lo, hi = self._bounds(units)
137
+ return widgets.BoundedFloatText(
138
+ value=self.to_display(value, units), min=lo, max=hi, step=None, layout=layout
139
+ )
140
+
141
+ def refresh(self, widget, value, units):
142
+ if isinstance(widget, widgets.BoundedFloatText):
143
+ lo, hi = self._bounds(units)
144
+ with widget.hold_trait_notifications():
145
+ widget.min, widget.max = lo, hi
146
+ widget.value = self.to_display(value, units)
147
+
148
+ def validate(self, value):
149
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
150
+ raise ValueError(f"{self.key}: expected a number, got {value!r}")
151
+ value = float(value)
152
+ if not math.isfinite(value):
153
+ raise ValueError(f"{self.key}: {value} is not a finite number")
154
+ # allow for round-off from converting through display units
155
+ if self.min is not None and value < self.min - 1e-9 * max(1.0, abs(self.min)):
156
+ raise ValueError(f"{self.key}: {value:g} is below the minimum {self.min:g}")
157
+ if self.max is not None and value > self.max + 1e-9 * max(1.0, abs(self.max)):
158
+ raise ValueError(f"{self.key}: {value:g} is above the maximum {self.max:g}")
159
+ return value
160
+
161
+
162
+ class ChoiceColumn(InputColumn):
163
+ """A dropdown; the row value is the chosen option's key.
164
+
165
+ options dict {key: data} or a list of keys. The data (e.g. a material property) is
166
+ available to your compute function via the column's `options` dict.
167
+ label optional callable(key, data, units) -> str for the dropdown text, e.g. to show
168
+ a property of each option in the current units
169
+ default initially selected key (default: the first)
170
+ """
171
+
172
+ width = "250px"
173
+
174
+ def __init__(self, key, header=None, options=(), label=None, default=None, width=None):
175
+ options = dict(options) if isinstance(options, dict) else {k: k for k in options}
176
+ if not options:
177
+ raise ValueError(f"ChoiceColumn {key!r} needs at least one option.")
178
+ super().__init__(key, header, next(iter(options)) if default is None else default, width)
179
+ self.options, self.label = options, label
180
+
181
+ def _choices(self, units):
182
+ if self.label is None:
183
+ return list(self.options)
184
+ return [(self.label(k, v, units), k) for k, v in self.options.items()]
185
+
186
+ def create_widget(self, value, units):
187
+ if value not in self.options:
188
+ raise ValueError(f"{value!r} is not one of the {self.key!r} options.")
189
+ return widgets.Dropdown(
190
+ options=self._choices(units), value=value, layout=widgets.Layout(width="100%")
191
+ )
192
+
193
+ def refresh(self, widget, value, units):
194
+ if self.label is not None:
195
+ widget.options = self._choices(units) # this resets the selection...
196
+ widget.value = value # ...so restore it
197
+
198
+ def validate(self, value):
199
+ if value not in self.options:
200
+ raise ValueError(f"{self.key}: {value!r} is not one of the options")
201
+ return value
202
+
203
+
204
+ class TextColumn(InputColumn):
205
+ """Free text per row (e.g. a name or tag); updates when the box loses focus."""
206
+
207
+ width = "160px"
208
+
209
+ def __init__(self, key, header=None, default="", width=None):
210
+ super().__init__(key, header, default, width)
211
+
212
+ def create_widget(self, value, units):
213
+ return widgets.Text(
214
+ value=value, continuous_update=False, layout=widgets.Layout(width="100%")
215
+ )
216
+
217
+ def validate(self, value):
218
+ if not isinstance(value, str):
219
+ raise ValueError(f"{self.key}: expected text, got {value!r}")
220
+ return value
221
+
222
+
223
+ class OutputColumn(Column):
224
+ """One computed value per row, shown right-aligned.
225
+
226
+ quantity name of the quantity in your UnitSystems, for conversion and {unit} in headers
227
+ fmt None (4 significant figures), a format string like "{:.2f}", or a callable
228
+ """
229
+
230
+ width = "150px"
231
+
232
+ def __init__(self, key, header=None, quantity=None, fmt=None, width=None):
233
+ super().__init__(key, header, width)
234
+ self.quantity, self.format = quantity, formatter(fmt)
235
+
236
+ def render(self, value, units) -> str:
237
+ return right_aligned(self.format(units.to_display(self.quantity, value)))
238
+
239
+
240
+ class NodeColumn(OutputColumn):
241
+ """Computed values *between* rows: n + 1 values for n rows.
242
+
243
+ The first value belongs to the top/inlet, shown in a leading row above the first element;
244
+ each row then shows the value at its bottom/outlet face.
245
+
246
+ inputs which ends are editable boundary conditions: ("first", "last") (default),
247
+ "first", "last" or ()
248
+ first_default default for the first end, in base units, or {unit-system name: value}
249
+ last_default the same for the last end
250
+ min, max bounds for the editable ends, in base units
251
+ """
252
+
253
+ width = "120px"
254
+
255
+ def __init__(self, key, header=None, quantity=None, inputs=("first", "last"),
256
+ first_default=0.0, last_default=0.0, min=None, max=None, fmt=None, width=None):
257
+ super().__init__(key, header, quantity, fmt, width)
258
+ inputs = (inputs,) if isinstance(inputs, str) else tuple(inputs)
259
+ if set(inputs) - {"first", "last"}:
260
+ raise ValueError("NodeColumn inputs must be drawn from 'first' and 'last'.")
261
+ self.inputs = inputs
262
+ self.ends = {
263
+ "first": NumberColumn(key, quantity=quantity, default=first_default, min=min, max=max),
264
+ "last": NumberColumn(key, quantity=quantity, default=last_default, min=min, max=max),
265
+ }
@@ -0,0 +1,4 @@
1
+ """Complete example applications built with ipyrowtable.
2
+
3
+ conduction steady conduction through a plane composite wall, with units and a profile plot
4
+ """
@@ -0,0 +1,340 @@
1
+ """
2
+ Steady-state conduction through a stack of flat plates (a plane composite wall).
3
+
4
+ A complete ipyrowtable application: a layer table (material and thickness in; conductance and
5
+ interface temperature out) with an SI / Imperial toggle and a live temperature-profile plot.
6
+
7
+ from ipyrowtable.examples.conduction import layer_stack_app
8
+ app = layer_stack_app(units="SI") # table and profile plot side by side
9
+ app
10
+
11
+ app.stack.results["q"] # heat flux, W/m² (base units)
12
+ app.stack.results.display("q") # the same in the units currently shown
13
+ app.stack.results.display("temperature") # interface temperatures, top surface first
14
+ app.stack.units = "Imperial" # same as clicking the toggle
15
+
16
+ To use your own solver, pass solver=your_function to LayerStack or layer_stack_app; it must
17
+ have the same signature and return values as `solve_series_conduction` (SI in and out).
18
+ The plot needs matplotlib (pip install ipyrowtable[plot]).
19
+ """
20
+
21
+ import numpy as np
22
+
23
+ from .. import (
24
+ ChoiceColumn,
25
+ LiveFigure,
26
+ NodeColumn,
27
+ NumberColumn,
28
+ OutputColumn,
29
+ RowTable,
30
+ Unit,
31
+ UnitSystem,
32
+ fmt_sig,
33
+ side_by_side,
34
+ )
35
+
36
+ __all__ = [
37
+ "MATERIALS",
38
+ "SI",
39
+ "IMPERIAL",
40
+ "UNIT_SYSTEMS",
41
+ "solve_series_conduction",
42
+ "LayerStack",
43
+ "TemperatureProfilePlot",
44
+ "layer_stack_app",
45
+ ]
46
+
47
+ # Thermal conductivities near 300 K, in W/(m·K). Edit or extend freely (always SI here).
48
+ MATERIALS = {
49
+ "Copper": 401.0,
50
+ "Aluminum": 237.0,
51
+ "Carbon steel": 60.5,
52
+ "Stainless steel (304)": 14.9,
53
+ "Concrete": 1.4,
54
+ "Glass": 1.4,
55
+ "Brick": 0.72,
56
+ "Gypsum board": 0.17,
57
+ "Wood (softwood)": 0.12,
58
+ "Fiberglass insulation": 0.04,
59
+ "Polystyrene foam": 0.03,
60
+ }
61
+
62
+ # ------------------------------------------------------------------------------ units
63
+ # Base units (what the solver uses): m, °C, W/m·K, W/m²·K, W/m², m²·K/W.
64
+
65
+ _W_TO_BTU_PER_HR = 1.0 / 0.29307107 # 1 W = 3.4121 Btu/hr (International Table Btu)
66
+ _M_TO_FT = 1.0 / 0.3048
67
+ _CONDUCTANCE_TO_IMPERIAL = _W_TO_BTU_PER_HR / _M_TO_FT**2 / 1.8
68
+
69
+ SI = UnitSystem("SI", {
70
+ "length": Unit("mm", 1000.0),
71
+ "temperature": Unit("°C"),
72
+ "conductivity": Unit("W/m·K"),
73
+ "conductance": Unit("W/m²·K"),
74
+ "heat_flux": Unit("W/m²"),
75
+ "resistance": Unit("m²·K/W"),
76
+ })
77
+
78
+ IMPERIAL = UnitSystem("Imperial", {
79
+ "length": Unit("in", 1.0 / 0.0254),
80
+ "temperature": Unit("°F", 1.8, 32.0),
81
+ "conductivity": Unit("Btu/hr·ft·°F", _W_TO_BTU_PER_HR / _M_TO_FT / 1.8),
82
+ "conductance": Unit("Btu/hr·ft²·°F", _CONDUCTANCE_TO_IMPERIAL),
83
+ "heat_flux": Unit("Btu/hr·ft²", _W_TO_BTU_PER_HR / _M_TO_FT**2),
84
+ "resistance": Unit("hr·ft²·°F/Btu", 1.0 / _CONDUCTANCE_TO_IMPERIAL),
85
+ })
86
+
87
+ UNIT_SYSTEMS = {"SI": SI, "Imperial": IMPERIAL}
88
+
89
+ # Starting layers, (material, thickness in that system's display units).
90
+ DEFAULT_LAYERS = {
91
+ "SI": (("Carbon steel", 10.0), ("Fiberglass insulation", 50.0)),
92
+ "Imperial": (("Carbon steel", 0.375), ("Fiberglass insulation", 2.0)),
93
+ }
94
+
95
+
96
+ # ------------------------------------------------------------------------------ solver
97
+
98
+
99
+ def solve_series_conduction(thicknesses, conductivities, T_top, T_bottom):
100
+ """Steady 1-D conduction through flat plates in series, per unit area (all SI).
101
+
102
+ Parameters
103
+ ----------
104
+ thicknesses : sequence of float
105
+ Layer thicknesses in m, ordered top to bottom.
106
+ conductivities : sequence of float
107
+ Thermal conductivity of each layer, W/(m·K).
108
+ T_top, T_bottom : float
109
+ Boundary surface temperatures, °C.
110
+
111
+ Returns
112
+ -------
113
+ conductances : ndarray, shape (n,)
114
+ Layer conductance k/L, W/(m²·K).
115
+ temperatures : ndarray, shape (n + 1,)
116
+ Interface temperatures in °C, top surface first, bottom surface last.
117
+ q : float
118
+ Heat flux in W/m², positive from top to bottom.
119
+ """
120
+ L = np.asarray(thicknesses, dtype=float)
121
+ k = np.asarray(conductivities, dtype=float)
122
+ conductances = k / L
123
+ resistances = 1.0 / conductances
124
+ q = (T_top - T_bottom) / resistances.sum()
125
+ temperatures = T_top - q * np.concatenate(([0.0], np.cumsum(resistances)))
126
+ return conductances, temperatures, q
127
+
128
+
129
+ # ------------------------------------------------------------------------------ the table
130
+
131
+
132
+ class LayerStack(RowTable):
133
+ """The layer table: material and thickness in; conductance and interface temperature out."""
134
+
135
+ def __init__(self, layers=None, T_top=None, T_bottom=None, units="SI", materials=None,
136
+ solver=solve_series_conduction, persist=None, persist_file=None):
137
+ """
138
+ layers : iterable of (material_name, thickness), top to bottom, in `units`
139
+ (mm for SI, inches for Imperial). Defaults to a steel + insulation example.
140
+ T_top : top boundary temperature in `units` (default 100 °C / 212 °F)
141
+ T_bottom : bottom boundary temperature in `units` (default 20 °C / 68 °F)
142
+ units : "SI" or "Imperial" (the toggle can change it later)
143
+ materials : dict {name: k in W/(m·K)}; defaults to MATERIALS
144
+ solver : function(thicknesses_m, k, T_top_C, T_bottom_C) -> (conductances, temps_C, q)
145
+ persist : key to save the inputs under between sessions (see RowTable); the
146
+ arguments above are then the initial values
147
+ persist_file : where to save; default beside the notebook
148
+ """
149
+ self.materials = dict(materials or MATERIALS)
150
+ self.solver = solver
151
+ if layers is None:
152
+ layers = DEFAULT_LAYERS[units]
153
+
154
+ super().__init__(
155
+ columns=[
156
+ ChoiceColumn(
157
+ "material",
158
+ "Material <span style='font-weight:normal; color:gray'>"
159
+ "(k in {conductivity})</span>",
160
+ options=self.materials,
161
+ label=lambda name, k, u: f"{name} (k = {u.to_display('conductivity', k):.3g})",
162
+ width="280px",
163
+ ),
164
+ NumberColumn("thickness", "Thickness ({unit})", quantity="length",
165
+ default={"SI": 10.0, "Imperial": 0.5}, min=0.0, width="120px"),
166
+ OutputColumn("conductance", "Conductance ({unit})", quantity="conductance",
167
+ width="200px"),
168
+ NodeColumn("temperature", "Temperature ({unit})", quantity="temperature",
169
+ first_default=100.0, last_default=20.0, fmt="{:.2f}", width="120px"),
170
+ ],
171
+ compute=self._compute,
172
+ rows=[{"material": m, "thickness": t} for m, t in layers],
173
+ edges={"temperature": (T_top, T_bottom)},
174
+ unit_systems=UNIT_SYSTEMS,
175
+ units=units,
176
+ leading_label="— top surface —",
177
+ item_name="layer",
178
+ summary=self._summary,
179
+ output_quantities={"q": "heat_flux", "R_total": "resistance", "U_total": "conductance"},
180
+ persist=persist,
181
+ persist_file=persist_file,
182
+ )
183
+
184
+ def _compute(self, inputs):
185
+ L = inputs.column("thickness").astype(float)
186
+ if np.any(L <= 0):
187
+ raise ValueError("Every layer needs a thickness greater than zero.")
188
+ k = np.array([self.materials[m] for m in inputs.column("material")])
189
+ T_top, T_bottom = inputs.edges["temperature"]
190
+ conductance, temperature, q = self.solver(L, k, T_top, T_bottom)
191
+ R_total = float(np.sum(1.0 / np.asarray(conductance, dtype=float)))
192
+ return {
193
+ "conductance": conductance,
194
+ "temperature": temperature,
195
+ "q": float(q),
196
+ "R_total": R_total,
197
+ "U_total": 1.0 / R_total,
198
+ }
199
+
200
+ @staticmethod
201
+ def _summary(results):
202
+ u = results.units
203
+ return (
204
+ f"Heat flux q = <b>{fmt_sig(results.display('q'))} {u.label('heat_flux')}</b>"
205
+ " (positive top → bottom)"
206
+ f" &nbsp;·&nbsp; total resistance = {fmt_sig(results.display('R_total'))}"
207
+ f" {u.label('resistance')}"
208
+ f" &nbsp;·&nbsp; overall U = {fmt_sig(results.display('U_total'))}"
209
+ f" {u.label('conductance')}"
210
+ )
211
+
212
+ # conveniences
213
+ @property
214
+ def T_top(self):
215
+ """The top-temperature input widget."""
216
+ return self.edge_widget("temperature", "first")
217
+
218
+ @property
219
+ def T_bottom(self):
220
+ """The bottom-temperature input widget."""
221
+ return self.edge_widget("temperature", "last")
222
+
223
+ @property
224
+ def layers(self):
225
+ """Current stack as a list of (material, thickness in display units), top to bottom."""
226
+ return [(row["material"], row["thickness"]) for row in self.rows]
227
+
228
+
229
+ # ------------------------------------------------------------------------------ the profile plot
230
+
231
+ _PLOT_THEMES = {
232
+ "light": dict(
233
+ surface="#fcfcfb", ink="#0b0b0b", secondary="#52514e", muted="#898781",
234
+ grid="#e1e0d9", axis="#c3c2b7", line="#2a78d6", bands=("#f0efec", "#e6e5e0"),
235
+ ),
236
+ "dark": dict(
237
+ surface="#1a1a19", ink="#ffffff", secondary="#c3c2b7", muted="#898781",
238
+ grid="#2c2c2a", axis="#383835", line="#3987e5", bands=("#242422", "#2e2e2b"),
239
+ ),
240
+ }
241
+
242
+
243
+ def _spread(centers, gap, limit):
244
+ """Nudge label positions apart so neighbours are at least `gap` apart, within [gap/2, limit]."""
245
+ y = [max(c, gap / 2) for c in centers]
246
+ for i in range(1, len(y)):
247
+ y[i] = max(y[i], y[i - 1] + gap)
248
+ if y and y[-1] > limit:
249
+ y[-1] = limit
250
+ for i in range(len(y) - 2, -1, -1):
251
+ y[i] = min(y[i], y[i + 1] - gap)
252
+ return y
253
+
254
+
255
+ class TemperatureProfilePlot(LiveFigure):
256
+ """Temperature-vs-depth plot for a LayerStack; redraws itself on every change.
257
+
258
+ size : figure size in inches (width, height)
259
+ theme : "light" or "dark", to match your notebook theme
260
+ dpi : render resolution; 200 is crisp on hi-DPI screens
261
+ """
262
+
263
+ def __init__(self, stack, size=(6.8, 4.4), theme="light", dpi=200):
264
+ self.colors = _PLOT_THEMES[theme]
265
+ super().__init__(stack, size=size, dpi=dpi, facecolor=self.colors["surface"])
266
+
267
+ def draw(self, fig, results):
268
+ c = self.colors
269
+ u = results.units
270
+ ax = fig.add_axes([0.13, 0.13, 0.55, 0.70], facecolor=c["surface"])
271
+ for side in ("top", "right"):
272
+ ax.spines[side].set_visible(False)
273
+ for side in ("left", "bottom"):
274
+ ax.spines[side].set_color(c["axis"])
275
+ ax.tick_params(colors=c["axis"], labelcolor=c["secondary"], labelsize=9, length=3)
276
+
277
+ depth = np.concatenate(([0.0], np.cumsum(results.display("thickness"))))
278
+ T = np.asarray(results.display("temperature"))
279
+ total = depth[-1]
280
+
281
+ # Layers as alternating neutral bands, separated by a thin surface-colored gap.
282
+ for i in range(len(depth) - 1):
283
+ ax.axhspan(depth[i], depth[i + 1], color=c["bands"][i % 2], lw=0, zorder=0)
284
+ for y in depth[1:-1]:
285
+ ax.axhline(y, color=c["surface"], lw=1.5, zorder=1)
286
+ ax.grid(axis="x", color=c["grid"], lw=0.75)
287
+ ax.set_axisbelow(True)
288
+
289
+ # The temperature profile: straight within each layer, kinked at interfaces.
290
+ ax.plot(T, depth, color=c["line"], lw=2, solid_joinstyle="round", solid_capstyle="round",
291
+ marker="o", ms=7, mfc=c["line"], mec=c["surface"], mew=1.5, zorder=3, clip_on=False)
292
+
293
+ lo, hi = float(T.min()), float(T.max())
294
+ pad = 0.08 * (hi - lo) if hi > lo else 1.0
295
+ ax.set_xlim(lo - pad, hi + pad)
296
+ ax.set_ylim(total, 0) # top surface at the top
297
+ ax.set_xlabel(f"Temperature ({u.label('temperature')})", color=c["secondary"], fontsize=9.5)
298
+ ax.set_ylabel(f"Depth from top surface ({u.label('length')})", color=c["secondary"],
299
+ fontsize=9.5)
300
+
301
+ ax.set_title("Temperature profile", loc="left", fontsize=11, color=c["ink"],
302
+ fontweight="bold", pad=22)
303
+ q = results.display("q")
304
+ direction = "top → bottom" if q >= 0 else "bottom → top"
305
+ ax.text(0, 1.03, f"Heat flux {fmt_sig(abs(q))} {u.label('heat_flux')}, {direction}",
306
+ transform=ax.transAxes, fontsize=9, color=c["secondary"], va="bottom")
307
+
308
+ # Material names in the right margin, with leader lines so thin layers stay readable.
309
+ fontsize = 9
310
+ axes_height_pt = fig.get_figheight() * ax.get_position().height * 72
311
+ gap = 1.35 * fontsize * total / axes_height_pt
312
+ centers = (depth[:-1] + depth[1:]) / 2
313
+ for name, y_band, y_label in zip(results["material"], centers,
314
+ _spread(centers, gap, total - gap / 2), strict=True):
315
+ ax.annotate(
316
+ name,
317
+ xy=(1.0, y_band), xycoords=("axes fraction", "data"),
318
+ xytext=(1.05, y_label), textcoords=("axes fraction", "data"),
319
+ ha="left", va="center", fontsize=fontsize, color=c["secondary"],
320
+ arrowprops=dict(arrowstyle="-", color=c["muted"], lw=0.75, shrinkA=2, shrinkB=0),
321
+ annotation_clip=False,
322
+ )
323
+
324
+
325
+ # ------------------------------------------------------------------------------ convenience
326
+
327
+
328
+ def layer_stack_app(plot_options=None, **stack_options):
329
+ """A LayerStack and its TemperatureProfilePlot side by side (wrapping on narrow screens).
330
+
331
+ stack_options go to LayerStack (layers, T_top, T_bottom, units, materials, solver);
332
+ plot_options go to TemperatureProfilePlot (size, theme, dpi).
333
+ The returned box has .stack and .plot attributes.
334
+ """
335
+ stack = LayerStack(**stack_options)
336
+ plot = TemperatureProfilePlot(stack, **(plot_options or {}))
337
+ app = side_by_side(stack, plot)
338
+ app.stack, app.plot = stack, plot
339
+ return app
340
+