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.
- ipyrowtable/__init__.py +53 -0
- ipyrowtable/columns.py +265 -0
- ipyrowtable/examples/__init__.py +4 -0
- ipyrowtable/examples/conduction.py +340 -0
- ipyrowtable/figure.py +64 -0
- ipyrowtable/formatting.py +40 -0
- ipyrowtable/layout.py +16 -0
- ipyrowtable/persistence.py +160 -0
- ipyrowtable/table.py +785 -0
- ipyrowtable/units.py +71 -0
- ipyrowtable-0.1.1.dist-info/METADATA +303 -0
- ipyrowtable-0.1.1.dist-info/RECORD +14 -0
- ipyrowtable-0.1.1.dist-info/WHEEL +4 -0
- ipyrowtable-0.1.1.dist-info/licenses/LICENSE +21 -0
ipyrowtable/__init__.py
ADDED
|
@@ -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,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" · total resistance = {fmt_sig(results.display('R_total'))}"
|
|
207
|
+
f" {u.label('resistance')}"
|
|
208
|
+
f" · 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
|
+
|