CoolPlot 1.0.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (110) hide show
  1. {coolplot-1.0.0/src/CoolPlot.egg-info → coolplot-1.2.0}/PKG-INFO +3 -3
  2. {coolplot-1.0.0 → coolplot-1.2.0}/pyproject.toml +2 -2
  3. {coolplot-1.0.0 → coolplot-1.2.0/src/CoolPlot.egg-info}/PKG-INFO +3 -3
  4. {coolplot-1.0.0 → coolplot-1.2.0}/src/CoolPlot.egg-info/SOURCES.txt +11 -0
  5. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/__init__.py +1 -0
  6. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/__version__.py +1 -1
  7. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/chart.py +173 -27
  8. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/compose/__init__.py +7 -4
  9. coolplot-1.2.0/src/coolplot/compose/branding.py +63 -0
  10. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/compose/builder.py +143 -20
  11. coolplot-1.2.0/src/coolplot/compose/corners.py +156 -0
  12. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/compose/layout.py +24 -6
  13. coolplot-1.2.0/src/coolplot/compose/rails.py +497 -0
  14. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/cycle.py +5 -2
  15. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/diagram_type.py +31 -7
  16. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/floats.py +12 -0
  17. coolplot-1.2.0/src/coolplot/core/images.py +140 -0
  18. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/quantities.py +4 -0
  19. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/state.py +25 -3
  20. coolplot-1.2.0/src/coolplot/core/textwidth.py +51 -0
  21. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/units.py +8 -1
  22. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/corelib.py +55 -1
  23. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/engine/background.py +45 -18
  24. coolplot-1.2.0/src/coolplot/engine/cache.py +126 -0
  25. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/engine/engine.py +73 -5
  26. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/engine/planning.py +62 -6
  27. coolplot-1.2.0/src/coolplot/engine/store.py +282 -0
  28. coolplot-1.2.0/src/coolplot/psychrometric.py +177 -0
  29. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/base.py +64 -1
  30. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/mpl.py +217 -2
  31. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/pdf.py +3 -1
  32. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/plotly.py +49 -4
  33. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/svg.py +100 -34
  34. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/web.py +7 -1
  35. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/scene.py +221 -7
  36. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/spec/__init__.py +5 -4
  37. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/spec/diagram.py +168 -17
  38. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/spec/settings.py +143 -14
  39. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/style/theme.py +18 -2
  40. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/style/values.py +2 -2
  41. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/cycles/paths.py +20 -3
  42. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/fluid.py +132 -18
  43. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/humid_air.py +170 -18
  44. coolplot-1.2.0/src/coolplot/thermo/provider.py +176 -0
  45. coolplot-1.2.0/src/coolplot/thermo/references.py +318 -0
  46. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_api_vocabulary.py +8 -6
  47. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_background.py +57 -0
  48. coolplot-1.2.0/tests/test_branding.py +550 -0
  49. coolplot-1.2.0/tests/test_curve_store.py +339 -0
  50. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_cycles.py +33 -0
  51. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_diagram.py +72 -6
  52. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_engine.py +2 -0
  53. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_events.py +14 -0
  54. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_examples.py +40 -3
  55. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_layout.py +11 -0
  56. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_native_core.py +165 -1
  57. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_pdf.py +1 -0
  58. coolplot-1.2.0/tests/test_psychro.py +501 -0
  59. coolplot-1.2.0/tests/test_rails.py +317 -0
  60. coolplot-1.2.0/tests/test_saturation_anchors.py +213 -0
  61. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_style.py +37 -1
  62. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_web.py +11 -0
  63. coolplot-1.0.0/src/coolplot/engine/cache.py +0 -73
  64. coolplot-1.0.0/src/coolplot/psychrometric.py +0 -93
  65. coolplot-1.0.0/src/coolplot/thermo/provider.py +0 -88
  66. coolplot-1.0.0/tests/test_psychro.py +0 -244
  67. {coolplot-1.0.0 → coolplot-1.2.0}/LICENSE +0 -0
  68. {coolplot-1.0.0 → coolplot-1.2.0}/README.md +0 -0
  69. {coolplot-1.0.0 → coolplot-1.2.0}/setup.cfg +0 -0
  70. {coolplot-1.0.0 → coolplot-1.2.0}/src/CoolPlot.egg-info/dependency_links.txt +0 -0
  71. {coolplot-1.0.0 → coolplot-1.2.0}/src/CoolPlot.egg-info/requires.txt +0 -0
  72. {coolplot-1.0.0 → coolplot-1.2.0}/src/CoolPlot.egg-info/top_level.txt +0 -0
  73. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/__init__.py +0 -0
  74. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/errors.py +0 -0
  75. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/events.py +0 -0
  76. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/implementation.py +0 -0
  77. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/limits.py +0 -0
  78. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/core/numbers.py +0 -0
  79. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/diagram.py +0 -0
  80. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/engine/__init__.py +0 -0
  81. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/export.py +0 -0
  82. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/interaction.py +0 -0
  83. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/__init__.py +0 -0
  84. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/static/scene_svg.js +0 -0
  85. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/render/ticks.py +0 -0
  86. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/spec/fluid.py +0 -0
  87. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/style/__init__.py +0 -0
  88. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/testing/__init__.py +0 -0
  89. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/testing/kit.py +0 -0
  90. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/testing/pdf_reader.py +0 -0
  91. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/__init__.py +0 -0
  92. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/cycles/__init__.py +0 -0
  93. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/cycles/models.py +0 -0
  94. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/isolines.py +0 -0
  95. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/limits.py +0 -0
  96. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/native.py +0 -0
  97. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/process.py +0 -0
  98. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/stepping.py +0 -0
  99. {coolplot-1.0.0 → coolplot-1.2.0}/src/coolplot/thermo/tracer.py +0 -0
  100. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_architecture.py +0 -0
  101. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_backend_conformance.py +0 -0
  102. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_contracts.py +0 -0
  103. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_native.py +0 -0
  104. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_numerics.py +0 -0
  105. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_plotly.py +0 -0
  106. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_scene_js.py +0 -0
  107. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_scene_render.py +0 -0
  108. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_spec.py +0 -0
  109. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_thermo.py +0 -0
  110. {coolplot-1.0.0 → coolplot-1.2.0}/tests/test_web_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CoolPlot
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Thermodynamic property diagrams with CoolProp as data source.
5
5
  Author-email: Jorrit Wronski <coolplot@jorrit.org>
6
6
  License: MIT License
@@ -19,14 +19,14 @@ Classifier: Intended Audience :: Science/Research
19
19
  Classifier: License :: OSI Approved :: MIT License
20
20
  Classifier: Programming Language :: Python :: 3
21
21
  Classifier: Programming Language :: Python :: 3 :: Only
22
- Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
26
25
  Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: Python :: 3.15
27
27
  Classifier: Topic :: Scientific/Engineering :: Physics
28
28
  Classifier: Topic :: Scientific/Engineering :: Visualization
29
- Requires-Python: >=3.10
29
+ Requires-Python: >=3.11
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  Requires-Dist: numpy>=1.22
@@ -9,7 +9,7 @@ description = "Thermodynamic property diagrams with CoolProp as data source."
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
11
11
  authors = [{name = "Jorrit Wronski", email = "coolplot@jorrit.org"}]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.11"
13
13
  dependencies = [
14
14
  "numpy>=1.22",
15
15
  "coolprop>=8.0",
@@ -20,11 +20,11 @@ classifiers = [
20
20
  "License :: OSI Approved :: MIT License",
21
21
  "Programming Language :: Python :: 3",
22
22
  "Programming Language :: Python :: 3 :: Only",
23
- "Programming Language :: Python :: 3.10",
24
23
  "Programming Language :: Python :: 3.11",
25
24
  "Programming Language :: Python :: 3.12",
26
25
  "Programming Language :: Python :: 3.13",
27
26
  "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: Python :: 3.15",
28
28
  "Topic :: Scientific/Engineering :: Physics",
29
29
  "Topic :: Scientific/Engineering :: Visualization",
30
30
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CoolPlot
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Thermodynamic property diagrams with CoolProp as data source.
5
5
  Author-email: Jorrit Wronski <coolplot@jorrit.org>
6
6
  License: MIT License
@@ -19,14 +19,14 @@ Classifier: Intended Audience :: Science/Research
19
19
  Classifier: License :: OSI Approved :: MIT License
20
20
  Classifier: Programming Language :: Python :: 3
21
21
  Classifier: Programming Language :: Python :: 3 :: Only
22
- Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
26
25
  Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: Python :: 3.15
27
27
  Classifier: Topic :: Scientific/Engineering :: Physics
28
28
  Classifier: Topic :: Scientific/Engineering :: Visualization
29
- Requires-Python: >=3.10
29
+ Requires-Python: >=3.11
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  Requires-Dist: numpy>=1.22
@@ -16,25 +16,31 @@ src/coolplot/interaction.py
16
16
  src/coolplot/psychrometric.py
17
17
  src/coolplot/scene.py
18
18
  src/coolplot/compose/__init__.py
19
+ src/coolplot/compose/branding.py
19
20
  src/coolplot/compose/builder.py
21
+ src/coolplot/compose/corners.py
20
22
  src/coolplot/compose/layout.py
23
+ src/coolplot/compose/rails.py
21
24
  src/coolplot/core/__init__.py
22
25
  src/coolplot/core/cycle.py
23
26
  src/coolplot/core/diagram_type.py
24
27
  src/coolplot/core/errors.py
25
28
  src/coolplot/core/events.py
26
29
  src/coolplot/core/floats.py
30
+ src/coolplot/core/images.py
27
31
  src/coolplot/core/implementation.py
28
32
  src/coolplot/core/limits.py
29
33
  src/coolplot/core/numbers.py
30
34
  src/coolplot/core/quantities.py
31
35
  src/coolplot/core/state.py
36
+ src/coolplot/core/textwidth.py
32
37
  src/coolplot/core/units.py
33
38
  src/coolplot/engine/__init__.py
34
39
  src/coolplot/engine/background.py
35
40
  src/coolplot/engine/cache.py
36
41
  src/coolplot/engine/engine.py
37
42
  src/coolplot/engine/planning.py
43
+ src/coolplot/engine/store.py
38
44
  src/coolplot/render/__init__.py
39
45
  src/coolplot/render/base.py
40
46
  src/coolplot/render/mpl.py
@@ -62,6 +68,7 @@ src/coolplot/thermo/limits.py
62
68
  src/coolplot/thermo/native.py
63
69
  src/coolplot/thermo/process.py
64
70
  src/coolplot/thermo/provider.py
71
+ src/coolplot/thermo/references.py
65
72
  src/coolplot/thermo/stepping.py
66
73
  src/coolplot/thermo/tracer.py
67
74
  src/coolplot/thermo/cycles/__init__.py
@@ -71,7 +78,9 @@ tests/test_api_vocabulary.py
71
78
  tests/test_architecture.py
72
79
  tests/test_backend_conformance.py
73
80
  tests/test_background.py
81
+ tests/test_branding.py
74
82
  tests/test_contracts.py
83
+ tests/test_curve_store.py
75
84
  tests/test_cycles.py
76
85
  tests/test_diagram.py
77
86
  tests/test_engine.py
@@ -84,6 +93,8 @@ tests/test_numerics.py
84
93
  tests/test_pdf.py
85
94
  tests/test_plotly.py
86
95
  tests/test_psychro.py
96
+ tests/test_rails.py
97
+ tests/test_saturation_anchors.py
87
98
  tests/test_scene_js.py
88
99
  tests/test_scene_render.py
89
100
  tests/test_spec.py
@@ -42,6 +42,7 @@ _LAZY = {
42
42
  "CalcSettings": ".spec",
43
43
  "LabelSettings": ".spec",
44
44
  "PageSettings": ".spec",
45
+ "BrandingSettings": ".spec",
45
46
  "Engine": ".engine",
46
47
  "Fluid": ".thermo",
47
48
  "DiagramType": ".core.diagram_type",
@@ -1,4 +1,4 @@
1
1
  # CoolPlot version information
2
- VERSION = (1, 0, 0, '')
2
+ VERSION = (1, 2, 0, '')
3
3
 
4
4
  __version__ = '.'.join(map(str, VERSION[:3])) + ''.join(VERSION[3:])
@@ -46,13 +46,15 @@ from .compose import compose
46
46
  from .core.cycle import Cycle, StateInput
47
47
  from .core.errors import PropertyError
48
48
  from .core.quantities import quantity_key
49
- from .core.state import StatePoint
49
+ from .core.state import StatePoint, with_mollier_temperature
50
50
  from .core.units import get_unit_system
51
51
  from .engine import BackgroundEngine, CurveCache, Engine, plan
52
52
  from .scene import Scene
53
- from .spec import CycleSpec, DiagramSpec, IsolineSet, get_settings
53
+ from .spec import CycleSpec, DiagramSpec, IsolineSet, SaturationAnchor, get_settings
54
+ from .spec.diagram import ANCHOR_KEYS
54
55
  from .style.theme import Theme, get_theme
55
56
  from .style.values import LineStyle, MarkerStyle
57
+ from .thermo.cycles.paths import resolve_state
56
58
 
57
59
 
58
60
  def _known_key(name, inputs):
@@ -135,6 +137,9 @@ def check_state(state: StatePoint, provider) -> StatePoint:
135
137
  """
136
138
  if "v" not in state and "rho" in state and provider.kind == "fluid" and state.rho > 0.0:
137
139
  state = StatePoint(v=1.0 / state.rho, **state.to_dict())
140
+ if provider.kind == "humid_air":
141
+ # A humid-air state made before 1.2 lacks the temperature scale of the Mollier chart
142
+ state = with_mollier_temperature(state)
138
143
  missing = [k for k in provider.quantities if k not in state]
139
144
  if missing:
140
145
  raise ValueError("The state {0} is not a state of {1}: it lacks {2}.".format(state, provider.name, missing))
@@ -205,6 +210,7 @@ class Chart:
205
210
  self.engine = Engine(cache)
206
211
  self.scene = Scene()
207
212
  self._default_renderer = None
213
+ self._file_renderer = None # the matplotlib renderer of save(), outside pyplot
208
214
  self._plot_size_pt = None
209
215
  try:
210
216
  check_fluid(self.engine, spec.fluid) # a misspelt fluid fails here, not at the first draw
@@ -306,6 +312,22 @@ class Chart:
306
312
  def theme(self, value):
307
313
  self.settings = self.spec.settings.with_(look=get_theme(value))
308
314
 
315
+ @property
316
+ def show_saturation(self):
317
+ """Whether the saturation curve is drawn whatever lines are set.
318
+
319
+ True by default: the quality lines 0 and 1 of a fluid with a
320
+ critical point, or the line of 100 % relative humidity, are drawn
321
+ even when the lines set leave them out, or are not set at all.
322
+ False hides the curve; None leaves it to the values of the quality
323
+ or humidity lines (a chart saved before 1.2).
324
+ """
325
+ return self.spec.show_saturation
326
+
327
+ @show_saturation.setter
328
+ def show_saturation(self, value):
329
+ self.spec = self.spec.with_(show_saturation=value)
330
+
309
331
  @property
310
332
  def title(self) -> str:
311
333
  """The title shown above the chart."""
@@ -358,7 +380,7 @@ class Chart:
358
380
  # Content
359
381
  # ------------------------------------------------------------------
360
382
  def set_lines(self, key: str, values=None, num: int = None, range=None, rounding: bool = None,
361
- labels: bool = None, points: int = None, units=None):
383
+ labels: bool = None, points: int = None, units=None, at_saturation=None):
362
384
  """Add or replace the family of lines of constant ``key``.
363
385
 
364
386
  Parameters
@@ -366,7 +388,8 @@ class Chart:
366
388
  key : str
367
389
  Quantity held constant: "T", "p", "h", "s", "rho", "u" or "Q"
368
390
  in a property diagram; "RH", "Twb", "h" or "v" in a
369
- psychrometric chart.
391
+ psychrometric chart, and also "T" (isotherms) in a Mollier
392
+ h,x chart.
370
393
  values : sequence, optional
371
394
  Explicit constant values, in the chart's units or in ``units``.
372
395
  num : int, optional
@@ -388,7 +411,27 @@ class Chart:
388
411
  points : int, optional
389
412
  Points per line; default from ``settings.calc``.
390
413
  units : str or UnitSystem, optional
391
- Units of ``values`` and ``range``; default the chart's units.
414
+ Units of ``values``, ``range`` and ``at_saturation``; default the
415
+ chart's units.
416
+ at_saturation : dict, optional
417
+ Place the lines through saturation states at even steps instead
418
+ of giving values: ``{"T": 10}`` puts a line through the saturated
419
+ vapour every 10 K of saturation temperature (in deg C charts at
420
+ 0, 10, 20 deg C), ``{"p": 2}`` every 2 bar of saturation
421
+ pressure. ``"Q": 0`` uses the saturated liquid (the bubble point
422
+ of a blend) instead of the vapour (the dew point), and
423
+ ``"range": (lo, hi)`` limits the saturation temperatures or
424
+ pressures. Steps and range are in ``units``. The labels show the
425
+ lines' own values ("s=2.321 kJ/kg/K"); ``"label": "saturation"``
426
+ shows the saturation state instead ("T_sat=0 deg C"). Charts of
427
+ a fluid only.
428
+
429
+ Examples
430
+ --------
431
+ Isentropes through the saturated vapour every 10 K, as compressor
432
+ lines read off at round evaporating temperatures:
433
+
434
+ >>> diagram.set_lines("s", at_saturation={"T": 10}, labels=True) # doctest: +SKIP
392
435
  """
393
436
  key = quantity_key(key)
394
437
  family = self._family(key)
@@ -404,9 +447,43 @@ class Chart:
404
447
  _checked_range(family, label, range_SI)
405
448
  if num is None:
406
449
  num = family.default_num
407
- self.spec = self.spec.with_isolines(IsolineSet(key, values_SI, num, range_SI, rounding, points, labels))
450
+ anchor = None
451
+ if at_saturation is not None:
452
+ if values is not None or range is not None:
453
+ raise ValueError("Give the lines either values or a range, or at_saturation, not both; a range "
454
+ "of saturation states goes into at_saturation as 'range'.")
455
+ anchor = self._saturation_anchor(at_saturation, units)
456
+ spec = self.spec.with_isolines(IsolineSet(key, values_SI, num, range_SI, rounding, points, labels, anchor))
457
+ if anchor is not None:
458
+ # Place the lines now, so a step that is too small or a fluid
459
+ # without a saturation curve is reported here, not at the next draw
460
+ self.engine.domain(spec)
461
+ self.spec = spec
408
462
  return key
409
463
 
464
+ def _saturation_anchor(self, at_saturation, units) -> SaturationAnchor:
465
+ """A SaturationAnchor from set_lines(at_saturation=...), converted from ``units`` to SI."""
466
+ if isinstance(at_saturation, SaturationAnchor):
467
+ return at_saturation
468
+ given = dict(at_saturation)
469
+ span = given.pop("range", None)
470
+ label = given.pop("label", "value")
471
+ qualities = [given.pop(name) for name in ("Q", "x", "q") if name in given] # written as the labels write it
472
+ if len(qualities) > 1:
473
+ raise ValueError("Give the vapour quality of the saturation states once, as 'Q' or 'x'.")
474
+ Q = float(qualities[0]) if qualities else 1.0
475
+ if len(given) != 1:
476
+ raise ValueError("at_saturation takes one step, by saturation temperature or pressure, e.g. "
477
+ "{{'T': 10}} or {{'p': 2, 'Q': 0}}, not {0}.".format(at_saturation))
478
+ (by, step), = given.items()
479
+ by = quantity_key(by)
480
+ if by not in ANCHOR_KEYS:
481
+ raise ValueError("Lines are placed by saturation temperature 'T' or pressure 'p', not '{0}'.".format(by))
482
+ unit = self._units(units)[by]
483
+ step_SI = float(step) / abs(unit.scale) # a difference: the scale only, no offset
484
+ span_SI = None if span is None else tuple(float(v) for v in unit.to_SI(span))
485
+ return SaturationAnchor(by, step_SI, Q, span_SI, label)
486
+
410
487
  def _family(self, key: str):
411
488
  """The line family of a quantity in this chart, or ValueError."""
412
489
  family = self.spec.diagram.family(key)
@@ -454,7 +531,7 @@ class Chart:
454
531
 
455
532
  def add_cycle(self, name, states=None, loops=None, paths=None, numbers: bool = True, units=None,
456
533
  line_style: LineStyle = None, marker_style: MarkerStyle = None, steps: int = 20,
457
- closed: bool = True, **arrays):
534
+ closed: bool = True, labels=None, **arrays):
458
535
  """Add or replace a cycle, drawn on top of the chart.
459
536
 
460
537
  Parameters
@@ -492,6 +569,11 @@ class Chart:
492
569
  Segments per drawn path.
493
570
  closed : bool
494
571
  Whether the default loop returns to the first state.
572
+ labels : dict, optional
573
+ ``{number: text}``: a text written next to a state, after its
574
+ number, e.g. ``{2: "ADP 11.2 deg C", 3: "+8.1 kJ/kg"}`` shows
575
+ "2: ADP 11.2 deg C". With ``numbers=False`` the text stands
576
+ alone and states without a label get nothing.
495
577
  **arrays
496
578
  Instead of ``states``: arrays of two properties, e.g.
497
579
  ``add_cycle("measured", p=[...], h=[...])``.
@@ -503,17 +585,18 @@ class Chart:
503
585
  cycle = name if isinstance(name, Cycle) else (states if isinstance(states, Cycle) else None)
504
586
  if cycle is not None:
505
587
  spec = self._spec_from_model(cycle, None if isinstance(name, Cycle) else name, loops, paths, numbers,
506
- line_style, marker_style, steps)
588
+ line_style, marker_style, steps, labels)
507
589
  else:
508
590
  unit_system = self._units(units)
509
591
  inputs = self._state_inputs(_states_from_arrays(states, arrays) if arrays else states, name, unit_system)
510
592
  spec = CycleSpec(str(name), inputs, loops or _default_loops(inputs, closed), paths or (), numbers,
511
- line_style, marker_style, steps)
593
+ line_style, marker_style, steps, () if labels is None else labels)
512
594
  self._compute_cycle(spec) # compute now, so that errors show up here
513
595
  self.spec = self.spec.with_cycle(spec)
514
596
  return spec.name
515
597
 
516
- def _spec_from_model(self, cycle: Cycle, name, loops, paths, numbers, line_style, marker_style, steps):
598
+ def _spec_from_model(self, cycle: Cycle, name, loops, paths, numbers, line_style, marker_style, steps,
599
+ labels=None):
517
600
  """A CycleSpec for a computed Cycle, after checking that it belongs to this chart's fluid."""
518
601
  if cycle.fluid and self._other_fluid(cycle.fluid):
519
602
  raise ValueError("Cycle '{0}' was computed for {1}, but this chart shows {2}.".format(
@@ -521,7 +604,8 @@ class Chart:
521
604
  states = tuple((n, check_state(state, self.fluid)) for n, state in cycle.states)
522
605
  cycle = replace(cycle, states=states) # with v filled in for a cycle computed before 0.7
523
606
  return CycleSpec.from_cycle(cycle, name=name, loops=loops, paths=paths, numbers=numbers,
524
- line_style=line_style, marker_style=marker_style, steps=steps)
607
+ line_style=line_style, marker_style=marker_style, steps=steps,
608
+ labels=labels)
525
609
 
526
610
  def _state_inputs(self, states, name, unit_system):
527
611
  """(number, StateInput) pairs from a list (numbered from 1) or a dict of given states."""
@@ -592,6 +676,24 @@ class Chart:
592
676
  return {n: states[n].in_units(unit_system) for n in sorted(states)}
593
677
  return {n: states[n] for n in sorted(states)}
594
678
 
679
+ def state(self, entry, units=None) -> StatePoint:
680
+ """The full state (SI units) from two properties in the chart's units or in ``units``.
681
+
682
+ ``entry`` is a dict as for a state of :meth:`add_cycle`, e.g.
683
+ ``chart.state({"T": 13, "RH": 95})`` on a psychrometric chart in
684
+ engineering units, or a StatePoint (returned as it is, after a
685
+ check that it belongs to this chart). A "via" needs a state before
686
+ it and is not accepted here.
687
+ """
688
+ given = state_input(entry, self._units(units), self.fluid)
689
+ if given.via is not None:
690
+ raise ValueError("A state with via '{0}' needs a state before it; give two properties.".format(
691
+ given.via))
692
+ try:
693
+ return resolve_state(self.fluid, given)
694
+ except PropertyError as e:
695
+ raise self._in_units(e) from None
696
+
595
697
  # ------------------------------------------------------------------
596
698
  # Interaction helpers
597
699
  # ------------------------------------------------------------------
@@ -629,7 +731,8 @@ class Chart:
629
731
  curves = self.engine.curves(spec, domain, plan(spec, domain))
630
732
  previous = {item.id: item for item in self.scene if item.role == "isoline_label"}
631
733
  cycles = [self.engine.cycle(spec, c) for c in spec.cycles]
632
- items, axes = compose(spec, domain, curves, self._plot_size_pt, previous, cycles)
734
+ items, axes = compose(spec, domain, curves, self._plot_size_pt, previous, cycles,
735
+ self.engine.source_lines(spec))
633
736
  self.scene.replace_all(items, axes=axes)
634
737
  return self.scene
635
738
 
@@ -662,18 +765,20 @@ class Chart:
662
765
  """Let background work finish before a script draws: previews first,
663
766
  then the final curves they make room for. Warns, instead of waiting
664
767
  for ever, when nothing finishes any more."""
665
- last = None
666
768
  for _ in range(10):
769
+ # Counted from before the scene: a job can end while the scene is
770
+ # built (a crashed process pool fails all of its jobs at once), and
771
+ # the retries it queues next only end in the wait after that scene
772
+ finished = self.engine.finished_jobs
667
773
  self.update_scene()
668
774
  ready, needed = self.engine.progress
669
775
  if not self.engine.busy and ready >= needed: # a failed line may still be due a retry
670
776
  return
671
- if self.engine.finished_jobs == last: # the last wait finished no job at all
777
+ self.wait()
778
+ if self.engine.finished_jobs == finished: # neither the scene nor the wait finished a job
672
779
  warnings.warn("Background work makes no progress, the drawing may be incomplete.",
673
780
  UserWarning, stacklevel=3)
674
781
  return
675
- last = self.engine.finished_jobs
676
- self.wait()
677
782
  warnings.warn("Background work did not finish, the drawing may be incomplete (is the cache "
678
783
  "smaller than the number of lines?).", UserWarning, stacklevel=3)
679
784
 
@@ -687,21 +792,62 @@ class Chart:
687
792
  ``.svg`` is written by the SVG writer and ``.html`` by the web
688
793
  renderer (a static page), neither of which needs matplotlib;
689
794
  every other extension (PNG, PDF, EPS, ...) goes through matplotlib,
690
- with ``kwargs`` such as ``dpi`` or ``pdf_fonts`` passed on. The
691
- canvas has the size of ``settings.page`` for every writer, unless
692
- ``width`` and ``height`` (pixels) are given for SVG or HTML.
795
+ with ``kwargs`` such as ``dpi`` or ``pdf_fonts`` passed on.
796
+
797
+ An SVG file has the size of ``settings.page``, stated in points
798
+ (``width``, ``height`` and ``unit`` passed as ``kwargs`` override
799
+ it). Matplotlib files are drawn into a figure of their own outside
800
+ pyplot, of the size of ``settings.page``, so that saving charts in
801
+ a server or a loop does not collect pyplot figures; after
802
+ :meth:`draw` or :meth:`show` made a figure, that figure is saved
803
+ instead, at its own size. An HTML page has the size of
804
+ ``settings.page`` in pixels, unless ``width`` and ``height`` are given.
693
805
  """
694
806
  extension = str(path).rsplit(".", 1)[-1].lower() if "." in str(path) else ""
695
- if extension in ("svg", "html", "htm"):
696
- from .render.svg import PX_PER_PT
697
- page = self.spec.settings.page
698
- kwargs.setdefault("width", round(page.width_pt * PX_PER_PT))
699
- kwargs.setdefault("height", round(page.height_pt * PX_PER_PT))
807
+ page = self.spec.settings.page
700
808
  if extension == "svg":
701
- from .render.svg import SvgRenderer
702
- self.draw(SvgRenderer(**kwargs)).save(path)
809
+ from .render.svg import PX_PER_PT, SvgRenderer
810
+ options = dict(width=page.width_pt * PX_PER_PT, height=page.height_pt * PX_PER_PT, unit="pt")
811
+ options.update(kwargs)
812
+ self.draw(SvgRenderer(**options)).save(path)
703
813
  elif extension in ("html", "htm"):
814
+ from .render.svg import PX_PER_PT
704
815
  from .render.web import WebRenderer
816
+ kwargs.setdefault("width", round(page.width_pt * PX_PER_PT))
817
+ kwargs.setdefault("height", round(page.height_pt * PX_PER_PT))
705
818
  self.draw(WebRenderer(**kwargs)).save(path)
706
- else:
819
+ elif self._default_renderer is not None:
707
820
  self.draw().save(path, **kwargs)
821
+ else:
822
+ self.draw(self._matplotlib_file_renderer()).save(path, **kwargs)
823
+
824
+ def _matplotlib_file_renderer(self):
825
+ """The renderer of :meth:`save` for matplotlib formats: a figure outside pyplot, sized by settings.page."""
826
+ size = self.spec.settings.page.size_in
827
+ if self._file_renderer is None:
828
+ from .render.mpl import MatplotlibRenderer
829
+ self._file_renderer = MatplotlibRenderer(use_pyplot=False, figsize=size)
830
+ elif tuple(self._file_renderer.figure.get_size_inches()) != tuple(size):
831
+ self._file_renderer.figure.set_size_inches(size) # the page settings changed since
832
+ return self._file_renderer
833
+
834
+ def uncached_curves(self) -> int:
835
+ """How many isolines of the chart are in neither part of the curve cache.
836
+
837
+ Lines in memory or in the cache's folder on disk (see
838
+ :class:`coolplot.engine.CurveCache`) do not count; looking for one
839
+ on disk loads it into memory, as the next update would. The answer
840
+ costs the calculation region of the chart (the T-p box and, for
841
+ mixtures, the phase envelope), which the next update needs anyway,
842
+ but no line is calculated. An application can use it to tell a
843
+ request that only draws from one that calculates.
844
+
845
+ It counts the final lines of the isoline families only: cycles are
846
+ left out, and with a background engine a line counts until its
847
+ final version is in the cache, also while a job for it is running.
848
+ """
849
+ spec = self.spec
850
+ domain = self.engine.domain(spec)
851
+ cache = self.engine.cache
852
+ return sum(1 for request in plan(spec, domain)
853
+ if cache.peek(self.engine.cache_key(spec, domain, request)) is None)
@@ -14,14 +14,17 @@ from .builder import compose as compose_python, label_text, scaled_theme
14
14
  __all__ = ["compose", "compose_python", "label_text", "scaled_theme"]
15
15
 
16
16
 
17
- def compose(spec, domain, curves, plot_size_pt=None, previous_labels=None, cycles=None):
17
+ def compose(spec, domain, curves, plot_size_pt=None, previous_labels=None, cycles=None, sources=()):
18
18
  """All scene items and the axes for a diagram, see :func:`coolplot.compose.builder.compose`.
19
19
 
20
20
  Composed by coolplot-core when it is installed and the native mode is
21
21
  "on" (see :func:`coolplot.corelib.enabled`), by the Python composer
22
- otherwise; both give the same scene.
22
+ otherwise; both give the same scene. coolplot-core does not compose
23
+ the branding (badge and watermark), so a branded chart always takes
24
+ the Python composer.
23
25
  """
24
- if corelib.enabled():
26
+ branding = spec.settings.branding
27
+ if corelib.enabled() and not (branding.has_badge or branding.watermark):
25
28
  try:
26
29
  result = corelib.compose(spec, domain, curves, plot_size_pt, previous_labels, cycles)
27
30
  except corelib.CoreRefusal as error:
@@ -32,4 +35,4 @@ def compose(spec, domain, curves, plot_size_pt=None, previous_labels=None, cycle
32
35
  result = None
33
36
  if result is not None:
34
37
  return result
35
- return compose_python(spec, domain, curves, plot_size_pt, previous_labels, cycles)
38
+ return compose_python(spec, domain, curves, plot_size_pt, previous_labels, cycles, sources)
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+ """The badge and the watermark of a chart, from its branding settings.
3
+
4
+ :func:`branding` turns :class:`coolplot.spec.settings.BrandingSettings`
5
+ into the :class:`coolplot.scene.Badge` and :class:`coolplot.scene.Watermark`
6
+ of the axes, sized in points for the plot area the labels are laid out
7
+ for. The badge is made in the corner of the settings (the lower right
8
+ one for "auto"); the composer then gives it its corner together with the
9
+ legend
10
+ (:func:`coolplot.compose.corners.place_corners`), so that an automatic
11
+ one never takes the corner of the other, and isoline labels keep clear of
12
+ both.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional, Sequence, Tuple
17
+
18
+ from ..core.images import load_picture
19
+ from ..scene import Badge, Watermark
20
+ from ..spec.diagram import DiagramSpec
21
+ from ..style.theme import Theme
22
+ from ..style.values import Font, TextStyle
23
+ from .layout import Frame
24
+
25
+ #: The watermark is never taller than this fraction of the plot area.
26
+ WATERMARK_MAX_HEIGHT = 0.8
27
+
28
+
29
+ def branding(spec: DiagramSpec, theme: Theme, frame: Frame,
30
+ sources: Sequence[str] = ()) -> Tuple[Optional[Badge], Optional[Watermark]]:
31
+ """(badge, watermark) for the chart; None for what the settings do not ask for.
32
+
33
+ ``theme`` is the theme with the page's font scale applied and
34
+ ``frame`` the plot area the labels are laid out in. ``sources`` are
35
+ the lines naming the sources of the properties and the versions, from
36
+ :meth:`coolplot.engine.Engine.source_lines` (the composer does not ask
37
+ CoolProp itself); the badge shows them above ``BrandingSettings.lines``.
38
+ The badge sits in the corner of the settings, the lower right one for
39
+ "auto", until the composer places it.
40
+ """
41
+ settings = spec.settings.branding
42
+ watermark = None
43
+ if settings.watermark:
44
+ pictures = tuple(load_picture(s) for s in settings.watermark)
45
+ width = settings.watermark_width * frame.width_pt
46
+ if width * pictures[0].aspect > WATERMARK_MAX_HEIGHT * frame.height_pt:
47
+ width = WATERMARK_MAX_HEIGHT * frame.height_pt / pictures[0].aspect
48
+ if width > 0.0:
49
+ watermark = Watermark(picture=pictures, width_pt=width, opacity=settings.watermark_opacity)
50
+ if not settings.has_badge:
51
+ return None, watermark
52
+ axes_style = theme.axes
53
+ font_scale = spec.settings.page.font_scale
54
+ text = TextStyle(font=Font(family=axes_style.tick_label.font.family, size_pt=settings.font_size_pt * font_scale),
55
+ color=axes_style.tick_label.color or "#000000", h_align="left", v_align="bottom")
56
+ location = "lower right" if settings.location == "auto" else settings.location
57
+ badge = Badge(location=location, logo=tuple(load_picture(s) for s in settings.logo),
58
+ logo_width_pt=settings.logo_width_pt, lines=tuple(sources) + settings.lines, text=text,
59
+ background=axes_style.background or "#ffffff", background_alpha=settings.background_alpha,
60
+ frame_color=axes_style.frame_color if settings.frame else None)
61
+ if badge.empty:
62
+ return None, watermark
63
+ return badge, watermark