triplot 1.1.0__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,171 @@
1
+ """The figure's size: free with the window, a fixed aspect ratio, or EXACT.
2
+
3
+ The case in point: a stack of first up-scans in one session file,
4
+ second up-scans in another, both dropped into Word side by side - and they
5
+ have to be the same size, with their axes boxes the same size and in the
6
+ same place, without fiddling. That rules out any layout that sizes itself:
7
+
8
+ * the figure has a PHYSICAL size, width x height in cm or inches;
9
+ * its MARGINS are physical too, and they fix the axes box exactly - the
10
+ numbers and the captions live inside them. A margin that sized itself to
11
+ the widest tick label would make "10.25" and "0.5" two different boxes.
12
+
13
+ The drawing is laid out in a space of `DESIGN_DPI` units per inch - the
14
+ logical DPI Qt uses for fonts on screen and in a QImage - so a 12 pt font is
15
+ 12/72 inch in the figure, on screen (scaled to fit the pane) and in every
16
+ export. Lengths here are kept in the chosen unit and converted when the unit
17
+ changes, so switching cm <-> in never moves anything.
18
+
19
+ UI-free: plain attributes, so the undo stack and the session file treat it
20
+ like any other object.
21
+ """
22
+
23
+ MODE_WINDOW = "window" # the figure is whatever the pane is
24
+ MODE_ASPECT = "aspect" # the pane shows it at a fixed width : height
25
+ MODE_SIZE = "size" # exact physical size and margins
26
+ MODES = (MODE_WINDOW, MODE_ASPECT, MODE_SIZE)
27
+ MODE_TITLES = {MODE_WINDOW: "follow the window",
28
+ MODE_ASPECT: "a fixed aspect ratio",
29
+ MODE_SIZE: "an exact size"}
30
+
31
+ UNIT_CM = "cm"
32
+ UNIT_IN = "in"
33
+ UNITS = (UNIT_CM, UNIT_IN)
34
+ PER_INCH = {UNIT_CM: 2.54, UNIT_IN: 1.0}
35
+
36
+ #: Drawing units per inch: Qt's logical DPI for fonts on screen and in a
37
+ #: QImage (checked at 150 % display scaling: 96, with the device
38
+ #: pixel ratio doing the rest). An SVG generator defaults to 72 and is set to
39
+ #: this explicitly on export.
40
+ DESIGN_DPI = 96.0
41
+
42
+ #: The lengths a layout holds, all in its `unit`.
43
+ LENGTHS = ("width", "height", "margin_left", "margin_right", "margin_top",
44
+ "margin_bottom")
45
+
46
+
47
+ class FigureLayout(object):
48
+ """How big the figure is, and where its axes box sits inside it."""
49
+
50
+ kind = "figure"
51
+
52
+ def __init__(self):
53
+ self.mode = MODE_WINDOW
54
+ self.unit = UNIT_CM
55
+ # A single-column journal figure, with room for 12 pt numbers and a
56
+ # 14 pt caption on the left and below.
57
+ self.width = 8.5
58
+ self.height = 6.5
59
+ self.margin_left = 1.9
60
+ self.margin_right = 0.3
61
+ self.margin_top = 0.3
62
+ self.margin_bottom = 1.5
63
+ #: Width : height for MODE_ASPECT.
64
+ self.aspect_w = 4.0
65
+ self.aspect_h = 3.0
66
+ #: Pixels per inch of a PNG export. An SVG is exact at any size.
67
+ self.dpi = 600
68
+ #: Room the PROGRAM grew a margin by for an axis that appeared on
69
+ #: its side (`MainWindow._room_for_axes`): `{side: [before, after]}`
70
+ #: in `unit`. When that axis goes, the margin goes back to `before`
71
+ #: (never below what is still drawn there) - unless it was set by
72
+ #: hand since, when it is no longer `after`. Not part of a
73
+ #: preset.
74
+ self.grown = {}
75
+
76
+ # ------------------------------------------------------------ lengths
77
+ def to_px(self, value):
78
+ """A length in this layout's unit, in drawing units."""
79
+ return float(value) / PER_INCH[self.unit] * DESIGN_DPI
80
+
81
+ def size_px(self):
82
+ """`(width, height)` of the whole figure in drawing units."""
83
+ return self.to_px(self.width), self.to_px(self.height)
84
+
85
+ def margins_px(self):
86
+ """`(left, right, top, bottom)` in drawing units."""
87
+ return (self.to_px(self.margin_left), self.to_px(self.margin_right),
88
+ self.to_px(self.margin_top), self.to_px(self.margin_bottom))
89
+
90
+ def axes_size(self):
91
+ """`(width, height)` of the axes box, in the layout's unit."""
92
+ return (self.width - self.margin_left - self.margin_right,
93
+ self.height - self.margin_top - self.margin_bottom)
94
+
95
+ def set_axes_size(self, width, height):
96
+ """Size the figure so its axes box is `width` x `height`, margins
97
+ unchanged."""
98
+ self.width = float(width) + self.margin_left + self.margin_right
99
+ self.height = float(height) + self.margin_top + self.margin_bottom
100
+
101
+ def inches(self):
102
+ return (self.width / PER_INCH[self.unit],
103
+ self.height / PER_INCH[self.unit])
104
+
105
+ def set_unit(self, unit):
106
+ """Change the unit, CONVERTING every length so nothing moves."""
107
+ if unit not in UNITS or unit == self.unit:
108
+ return
109
+ factor = PER_INCH[unit] / PER_INCH[self.unit]
110
+ for name in LENGTHS:
111
+ setattr(self, name, getattr(self, name) * factor)
112
+ self.grown = dict((side, [pair[0] * factor, pair[1] * factor])
113
+ for side, pair in self.grown.items())
114
+ self.unit = unit
115
+
116
+ def is_valid(self):
117
+ axes_w, axes_h = self.axes_size()
118
+ return (min(self.width, self.height) > 0 and axes_w > 0
119
+ and axes_h > 0 and min(getattr(self, n) for n in LENGTHS) >= 0
120
+ and self.aspect_w > 0 and self.aspect_h > 0 and self.dpi > 0)
121
+
122
+ # ------------------------------------------------------------- state
123
+ FIELDS = ("mode", "unit") + LENGTHS + ("aspect_w", "aspect_h", "dpi")
124
+
125
+ def to_state(self):
126
+ return dict((name, getattr(self, name)) for name in self.FIELDS)
127
+
128
+ def load_state(self, state):
129
+ """Take what is legal from a saved state; keep the rest."""
130
+ if not isinstance(state, dict):
131
+ return self
132
+ if state.get("mode") in MODES:
133
+ self.mode = state["mode"]
134
+ if state.get("unit") in UNITS:
135
+ self.unit = state["unit"]
136
+ for name in LENGTHS + ("aspect_w", "aspect_h"):
137
+ try:
138
+ value = float(state[name])
139
+ except (KeyError, TypeError, ValueError):
140
+ continue
141
+ if value >= 0:
142
+ setattr(self, name, value)
143
+ try:
144
+ self.dpi = max(30, min(2400, int(state.get("dpi", self.dpi))))
145
+ except (TypeError, ValueError):
146
+ pass
147
+ return self
148
+
149
+ def copy(self):
150
+ made = FigureLayout().load_state(self.to_state())
151
+ made.grown = clean_grown(self.grown)
152
+ return made
153
+
154
+
155
+ #: The page's four margins, as `FigureLayout` names them.
156
+ MARGIN_SIDES = ("left", "right", "top", "bottom")
157
+
158
+
159
+ def clean_grown(value):
160
+ """A `FigureLayout.grown` read from a file: what is legal of it."""
161
+ out = {}
162
+ if not isinstance(value, dict):
163
+ return out
164
+ for side, pair in value.items():
165
+ try:
166
+ before, after = float(pair[0]), float(pair[1])
167
+ except (TypeError, ValueError, IndexError, KeyError):
168
+ continue
169
+ if side in MARGIN_SIDES and 0 <= before <= after:
170
+ out[side] = [before, after]
171
+ return out
@@ -0,0 +1,430 @@
1
+ """What an analysis label SAYS: the words are the user's, the number is not.
2
+
3
+ A label is a TEMPLATE. `{}` is the measured value, filled in by the program
4
+ every time the label is drawn; everything else is the user's text, with the
5
+ figure markup (`*T*` italic, `_{on}` subscript, `\\Delta`). The default
6
+ templates are `*T*_{on} = {}`, `*T*_{end} = {}`, `*T*_{g} = {}` and
7
+ `\\Delta*H* = {}`.
8
+
9
+ The rules, and why each one is where the program takes control away:
10
+
11
+ 1. **The number is never typed.** It reaches a label only through `{}`, so
12
+ re-measuring, switching the axis to Kelvin, or giving the sample its
13
+ molar mass can never leave a stale number on the figure. (Before this,
14
+ a label stored its number as text and had to be rewritten on every
15
+ change - and a typed one never was.)
16
+ 2. **A unit is a CONVERSION, not a relabelling** - after `{}`, or in the
17
+ number format (`%.0f degF`). `{} degF` on an onset shows the onset in
18
+ Fahrenheit; `{} kJ/mol` on an enthalpy multiplies by the molar mass. The
19
+ label then says a different unit from the axes but the same thing: it
20
+ still stands at the right place on the curve. With no unit the value
21
+ follows the axes: temperatures in the x axis's unit, an enthalpy per
22
+ mole when the y axis is per mole. Units are read in any case, and
23
+ `F`, `C`, `degF` mean degrees.
24
+ 3. **A unit the quantity cannot be converted into is refused**: `{} J/g` on
25
+ a temperature is drawn in the temperature's own unit, and the settings
26
+ say why. A number is never drawn beside a unit it is not in.
27
+ 4. **A conversion that needs what is missing shows `?`**: `{} kJ/mol`
28
+ without a molar mass is `? kJ/mol`, and every export says NO MOLAR MASS -
29
+ the same rule as a per-mole axis. Nothing is assumed.
30
+ 5. **The digits are the user's** (`core/numbers.py`): rounding changes how
31
+ exact a number looks, never what it is, and the right precision depends
32
+ on the instrument and the claim - which the program does not know.
33
+ 6. **Free text is free.** A number typed by hand ("lit. 148 degC") is
34
+ allowed, because it may be a reference value; but one next to a unit of
35
+ the analysed quantity is flagged in the settings as not the measurement.
36
+
37
+ UI-free, like the rest of `core/`.
38
+ """
39
+
40
+ import re
41
+
42
+ from . import numbers
43
+ from . import units
44
+
45
+ TEMPERATURE = "temperature"
46
+ ENTHALPY = "enthalpy"
47
+ HEAT_FLOW = "heat flow"
48
+ #: An SDT run's m% at a temperature: "%" of the sample mass, or
49
+ #: "mg" - which needs the sample mass unless the file recorded the mg.
50
+ MASS = "mass"
51
+
52
+ #: The template an analysis carries until somebody writes their own, by
53
+ #: model. Checked in order: a glass transition's fields also carry an
54
+ #: "Onset x".
55
+ DEFAULTS = (
56
+ # The bare number: "99%".
57
+ ("Mass at", "{}"),
58
+ ("Glass", "*T*_{g} = {}"),
59
+ ("Endset", "*T*_{end} = {}"),
60
+ ("Onset", "*T*_{on} = {}"),
61
+ ("Integration", "\\Delta*H* = {}"),
62
+ ("Peak height", "*q*_{peak} = {}"),
63
+ ("Signal change", "\\Delta*q* = {}"),
64
+ )
65
+
66
+ _DEG = "°"
67
+
68
+ #: Every unit a label may ask for, per quantity, as a factor from the base
69
+ #: unit (degC, J/g, W/g) and what that factor needs.
70
+ #: `(factor, needs)`: needs is None, "mass" (grams) or "molar" (g/mol).
71
+ _ENTHALPY_UNITS = {
72
+ "J/g": (1.0, None), "mJ/mg": (1.0, None), "kJ/g": (1e-3, None),
73
+ "J/mol": (1.0, "molar"), "kJ/mol": (1e-3, "molar"),
74
+ "J": (1.0, "mass"), "mJ": (1e3, "mass"), "kJ": (1e-3, "mass"),
75
+ }
76
+ _HEAT_FLOW_UNITS = {
77
+ "W/g": (1.0, None), "mW/mg": (1.0, None),
78
+ "W": (1.0, "mass"), "mW": (1e3, "mass"),
79
+ "W/mol": (1.0, "molar"), "mW/mol": (1e3, "molar"),
80
+ }
81
+ _TEMPERATURE_UNITS = {
82
+ _DEG + "C": units.TEMP_C, "degC": units.TEMP_C,
83
+ _DEG + "F": units.TEMP_F, "degF": units.TEMP_F,
84
+ "K": units.TEMP_K,
85
+ }
86
+ #: From percent of the sample mass: mg = % / 100 x the mass in mg, which is
87
+ #: 10 x the mass in grams.
88
+ _MASS_UNITS = {"%": (1.0, None), "mg": (10.0, "mass")}
89
+ _BY_QUANTITY = {TEMPERATURE: _TEMPERATURE_UNITS, ENTHALPY: _ENTHALPY_UNITS,
90
+ HEAT_FLOW: _HEAT_FLOW_UNITS, MASS: _MASS_UNITS}
91
+
92
+ _ALL_UNITS = sorted(set(_ENTHALPY_UNITS) | set(_HEAT_FLOW_UNITS)
93
+ | set(_TEMPERATURE_UNITS) | set(_MASS_UNITS),
94
+ key=len, reverse=True)
95
+ #: Other spellings of the temperature units, lower case.
96
+ _ALIASES = {"c": _DEG + "C", "f": _DEG + "F", "k": "K",
97
+ _DEG + "k": "K", "degk": "K"}
98
+ _UNIT = "(?:{})(?![A-Za-z/])".format(
99
+ "|".join(re.escape(u) for u in _ALL_UNITS))
100
+ #: In a label, after `{}`: the known units in any case, and C / F.
101
+ _LABEL_UNIT = "(?i:{}|[cf])(?![A-Za-z/])".format(
102
+ "|".join(re.escape(u) for u in _ALL_UNITS))
103
+
104
+
105
+ def canonical_unit(text):
106
+ """A unit as the program writes it ("degF" -> the degree-F sign,
107
+ "kj/mol" -> "kJ/mol"), or None for anything it does not know."""
108
+ token = str(text or "").strip().replace(" ", "")
109
+ lowered = token.lower()
110
+ if lowered in _ALIASES:
111
+ return _ALIASES[lowered]
112
+ for unit in _ALL_UNITS:
113
+ if unit.lower() == lowered:
114
+ return (_TEMPERATURE_UNITS.get(unit) and
115
+ units.TEMPERATURE_LABEL[_TEMPERATURE_UNITS[unit]]) or unit
116
+ return None
117
+
118
+
119
+ def normalise_format(spec):
120
+ """A number format that may carry a unit (`%.0f degF`), canonical."""
121
+ return numbers.normalise(spec, canonical_unit)
122
+ #: `{}`, and the unit right after it if one was written. Not `_{}` or
123
+ #: `^{}`, which are markup (an empty subscript).
124
+ _PLACEHOLDER = re.compile(r"(?<![_^])\{\}(?:(\s*)(" + _LABEL_UNIT + "))?")
125
+ _TYPED = re.compile(r"(?<![\w.])[-+]?\d+(?:[.,]\d+)?\s*(" + _UNIT + ")")
126
+
127
+
128
+ class Rendered(object):
129
+ """A label as drawn: its text and what is wrong with it.
130
+
131
+ `problems` is `[(kind, message), ...]`: "missing" (a value that cannot
132
+ be given without a molar or sample mass - exports say so), "unit" (a
133
+ unit the quantity cannot be put in) and "typed" (a number written by
134
+ hand beside a unit of the quantity: not the measurement).
135
+ """
136
+
137
+ def __init__(self, text, problems=(), value_text=""):
138
+ self.text = text
139
+ self.problems = list(problems)
140
+ self.value_text = value_text
141
+
142
+ def missing(self):
143
+ return [m for kind, m in self.problems if kind == "missing"]
144
+
145
+
146
+ def quantity_of(model_name):
147
+ """What an analysis of this model reports: a temperature, an enthalpy
148
+ or a heat flow."""
149
+ name = str(model_name)
150
+ if "Integration" in name:
151
+ return ENTHALPY
152
+ lowered = name.lower()
153
+ if lowered.startswith("mass at"):
154
+ return MASS
155
+ if "height" in lowered or "change" in lowered:
156
+ return HEAT_FLOW
157
+ return TEMPERATURE
158
+
159
+
160
+ def default_template(analysis):
161
+ for word, template in DEFAULTS:
162
+ if word in analysis.model_name:
163
+ return template
164
+ return "%s = {}" % analysis.model_name
165
+
166
+
167
+ def template_of(analysis):
168
+ return (analysis.label if analysis.label is not None
169
+ else default_template(analysis))
170
+
171
+
172
+ def result(analysis):
173
+ """`(value, quantity)` in the base unit (degC, J/g, W/g), value None
174
+ when the analysis carries no decoded number."""
175
+ from .model import number
176
+ name = analysis.model_name
177
+ fields = analysis.fields
178
+ quantity = quantity_of(name)
179
+ if quantity == ENTHALPY:
180
+ return number(fields.get("Enthalpy (normalized)")), quantity
181
+ if quantity == MASS:
182
+ return number(fields.get("Mass")), quantity
183
+ if quantity == HEAT_FLOW:
184
+ for key, text in fields.items():
185
+ if key in ("Model", "Cursor x", "Cursor x1", "segment", "prog",
186
+ "attribution"):
187
+ continue
188
+ value = number(text)
189
+ if value is None:
190
+ continue
191
+ if "mW" in str(text) and "/" not in str(text):
192
+ mass = _mass(analysis)
193
+ return ((value / 1000.0 / mass) if mass else None), quantity
194
+ return value, quantity
195
+ return None, quantity
196
+ if "Glass" in name:
197
+ return number(fields.get("Midpoint")), quantity
198
+ if "Endset" in name:
199
+ return number(fields.get("Endset x")), quantity
200
+ if "Onset" in name:
201
+ return number(fields.get("Onset x")), quantity
202
+ return analysis.value(), quantity
203
+
204
+
205
+ def units_of(quantity):
206
+ """The units a result of `quantity` can be shown in, in the order a
207
+ list offers them."""
208
+ return {TEMPERATURE: [_DEG + "C", "K", _DEG + "F"],
209
+ ENTHALPY: ["J/g", "kJ/mol", "J/mol", "kJ/g", "J", "mJ"],
210
+ HEAT_FLOW: ["W/g", "mW", "W/mol", "mW/mol", "W"],
211
+ MASS: ["%", "mg"]}.get(quantity, [])
212
+
213
+
214
+ def natural_unit(quantity, doc):
215
+ """The unit a value takes when the label names none: the axes'."""
216
+ if quantity == TEMPERATURE:
217
+ unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
218
+ return units.TEMPERATURE_LABEL.get(unit, _DEG + "C")
219
+ if quantity == MASS:
220
+ return getattr(doc, "weight_unit", "%") if doc else "%"
221
+ y_unit = getattr(doc, "y_unit", units.UNIT_W_G) if doc else units.UNIT_W_G
222
+ if quantity == ENTHALPY:
223
+ return "kJ/mol" if y_unit == units.UNIT_W_MOL else "J/g"
224
+ return y_unit
225
+
226
+
227
+ def number_format(analysis, doc):
228
+ from . import style
229
+ return style.value(doc, analysis, "number_format")
230
+
231
+
232
+ #: An integration's peak temperature, where a label asks for it.
233
+ _PEAK = re.compile(r"\{Tp\}")
234
+ #: What "Tp shown" adds after an integration's own words.
235
+ PEAK_TEMPLATE = "*T*_{p} = {Tp}"
236
+
237
+
238
+ def peak_text(analysis, doc=None):
239
+ """An integration's peak temperature, written in the axes' temperature
240
+ unit with the house temperature format ("124 \u00b0C"), or "?"."""
241
+ from . import style
242
+ from .model import number
243
+ value = number(getattr(analysis, "fields", {}).get("Peak temperature"))
244
+ if value is None:
245
+ return "? " + natural_unit(TEMPERATURE, doc)
246
+ unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
247
+ spec = (style.figure_value(doc, "temperature_format") if doc is not None
248
+ else style.preference("temperature_format"))
249
+ converted = float(units.from_celsius(value, unit))
250
+ return "{} {}".format(numbers.write(converted, spec, numbers.TEMPERATURE),
251
+ units.TEMPERATURE_LABEL.get(unit, unit))
252
+
253
+
254
+ def shows_peak(analysis, doc=None):
255
+ """True when an integration's label is to give its Tp as well."""
256
+ from . import style
257
+ if "Integration" not in getattr(analysis, "model_name", ""):
258
+ return False
259
+ return style.value(doc, analysis, "show_peak") == style.PEAK_ON
260
+
261
+
262
+ def render(analysis, doc=None):
263
+ """The label's text, with every `{}` filled in, and its problems."""
264
+ template = template_of(analysis)
265
+ # Tp: where the label says `{Tp}`, else after it when asked for.
266
+ if shows_peak(analysis, doc) and not _PEAK.search(template):
267
+ template = template + ", " + PEAK_TEMPLATE
268
+ value, quantity = result(analysis)
269
+ spec = number_format(analysis, doc)
270
+ problems = []
271
+ wanted = _BY_QUANTITY[quantity]
272
+ natural = natural_unit(quantity, doc)
273
+ # The format's unit, if it names one this quantity can be in. The
274
+ # house style's formats cover several quantities (an enthalpy and a
275
+ # peak height share one), so a unit that does not fit is simply not
276
+ # for this one; a format chosen for THIS analysis is held to it.
277
+ format_unit = numbers.split(spec)[1]
278
+ if format_unit is not None and canonical_unit(format_unit) not in wanted:
279
+ if analysis.number_format is not None:
280
+ problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
281
+ .format(format_unit, quantity, natural)))
282
+ format_unit = None
283
+ # The analysis's own unit, chosen in its settings (J/mol or kJ/mol
284
+ # could not be set any other way).
285
+ own = canonical_unit(getattr(analysis, "unit", None))
286
+ if own is not None and own not in wanted:
287
+ problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
288
+ .format(own, quantity, natural)))
289
+ own = None
290
+ default = own or format_unit or natural
291
+ shown = []
292
+
293
+ def fill(match):
294
+ typed = canonical_unit(match.group(2)) if match.group(2) else None
295
+ unit = typed or default
296
+ if typed and typed not in wanted:
297
+ problems.append(("unit", "'{}' is not a unit of {}: shown in {}"
298
+ .format(match.group(2), quantity,
299
+ default)))
300
+ unit = default
301
+ text = _in_unit(analysis, value, quantity, unit, spec, problems)
302
+ shown.append(text)
303
+ return text
304
+
305
+ text = _PLACEHOLDER.sub(fill, template)
306
+ if _PEAK.search(text):
307
+ text = _PEAK.sub(lambda _m: peak_text(analysis, doc), text)
308
+ if analysis.label is not None:
309
+ for match in _TYPED.finditer(
310
+ _PEAK.sub("", _PLACEHOLDER.sub("", template))):
311
+ if canonical_unit(match.group(1)) in wanted:
312
+ problems.append(("typed", "'{}' is typed by hand, not the "
313
+ "measurement; {{}} shows the "
314
+ "measured value".format(
315
+ match.group(0).strip())))
316
+ return Rendered(text, problems, shown[0] if shown else "")
317
+
318
+
319
+ def _in_unit(analysis, value, quantity, unit, spec, problems):
320
+ """`value` (base unit) converted to `unit` and written, with the unit."""
321
+ if quantity == MASS:
322
+ # The milligrams the file recorded are used as they are: a run
323
+ # whose percentage has no known reference (a negative recorded
324
+ # weight) still has its mg (TRI-FORMAT.md section 3b).
325
+ from .model import number
326
+ if unit == "mg":
327
+ recorded = number(getattr(analysis, "fields", {}).get(
328
+ "Mass (mg)"))
329
+ if recorded is not None:
330
+ return "{} mg".format(numbers.write(recorded, spec,
331
+ numbers.MASS))
332
+ if value is None:
333
+ return "? " + unit
334
+ if value is None:
335
+ return "? " + unit
336
+ if quantity == TEMPERATURE:
337
+ target = _TEMPERATURE_UNITS.get(unit, units.TEMP_C)
338
+ converted = float(units.from_celsius(value, target))
339
+ return "{} {}".format(numbers.write(converted, spec,
340
+ numbers.TEMPERATURE),
341
+ units.TEMPERATURE_LABEL.get(target, unit))
342
+ table = _BY_QUANTITY[quantity]
343
+ factor, needs = table.get(unit, (1.0, None))
344
+ if needs == "molar":
345
+ molar = getattr(getattr(analysis, "scan", None), "molar_mass", None)
346
+ if not molar:
347
+ problems.append(("missing", "NO MOLAR MASS: {} cannot be given "
348
+ "in {}".format(quantity, unit)))
349
+ return "? " + unit
350
+ factor *= float(molar)
351
+ elif needs == "mass":
352
+ mass = _mass(analysis)
353
+ if not mass:
354
+ problems.append(("missing", "NO SAMPLE MASS: {} cannot be given "
355
+ "in {}".format(quantity, unit)))
356
+ return "? " + unit
357
+ factor *= float(mass)
358
+ fallback = numbers.MASS if quantity == MASS else numbers.VALUE
359
+ return "{} {}".format(numbers.write(value * factor, spec, fallback),
360
+ unit)
361
+
362
+
363
+ def _mass(analysis):
364
+ scan = getattr(analysis, "scan", None)
365
+ sample = getattr(scan, "sample", None)
366
+ return getattr(sample, "mass_g", None)
367
+
368
+
369
+ def convert_heat_flow(value, from_unit, to_unit, scan):
370
+ """A heat flow (an offset, a peak height) from one unit to another, or
371
+ None when the conversion needs a mass the scan does not have."""
372
+ table = _HEAT_FLOW_UNITS
373
+ source, target = canonical_unit(from_unit), canonical_unit(to_unit)
374
+ if source not in table or target not in table:
375
+ return None
376
+ factors = []
377
+ for unit in (source, target):
378
+ factor, needs = table[unit]
379
+ if needs == "molar":
380
+ molar = getattr(scan, "molar_mass", None)
381
+ if not molar:
382
+ return None
383
+ factor *= float(molar)
384
+ elif needs == "mass":
385
+ mass = getattr(getattr(scan, "sample", None), "mass_g", None)
386
+ if not mass:
387
+ return None
388
+ factor *= float(mass)
389
+ factors.append(factor)
390
+ return float(value) / factors[0] * factors[1]
391
+
392
+
393
+ _PLAIN_NAMES = {"Enthalpy (normalized)": "Enthalpy"}
394
+
395
+
396
+ def results(analysis, doc=None):
397
+ """`[(name, text), ...]`: every result an analysis carries beyond its
398
+ cursors, named plainly and written in the figure's units and formats -
399
+ what the settings show under "Results"."""
400
+ from .model import number
401
+ from . import style
402
+ out = []
403
+ skip = ("Model", "segment", "prog", "attribution")
404
+ for key, text in analysis.fields.items():
405
+ if key in skip or "cursor" in key.lower():
406
+ continue
407
+ value = number(text)
408
+ if value is None:
409
+ continue
410
+ # Plain names, not TRIOS's field names ("Onset x", "Enthalpy
411
+ # (normalized)"), which mean nothing without the file format.
412
+ name = key[:-2] if key.endswith(" x") else key
413
+ name = _PLAIN_NAMES.get(name, name)
414
+ raw = str(text)
415
+ if _DEG + "C" in raw or "degC" in raw or key.endswith(" x") \
416
+ or key in ("Midpoint", "Peak temperature"):
417
+ unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
418
+ shown = numbers.write(units.from_celsius(value, unit),
419
+ style.figure_value(doc,
420
+ "temperature_format"),
421
+ numbers.TEMPERATURE)
422
+ out.append((name, "{} {}".format(
423
+ shown, units.TEMPERATURE_LABEL.get(unit, unit))))
424
+ continue
425
+ found = re.search(r"[A-Za-z%/]+(?:/[A-Za-z]+)?\s*$", raw)
426
+ unit = found.group(0).strip() if found else ""
427
+ shown = numbers.write(value, style.figure_value(doc, "value_format"),
428
+ numbers.VALUE)
429
+ out.append((name, "{} {}".format(shown, unit).strip()))
430
+ return out