icplot 0.1.5__tar.gz → 0.1.7__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 (51) hide show
  1. {icplot-0.1.5/src/icplot.egg-info → icplot-0.1.7}/PKG-INFO +5 -2
  2. icplot-0.1.7/pyproject.toml +155 -0
  3. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/cairo_interface.py +1 -1
  4. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/gantt/gantt.py +7 -0
  5. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/gantt/gantt_renderer.py +5 -4
  6. icplot-0.1.7/src/icplot/geometry.py +209 -0
  7. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/axis.py +4 -2
  8. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/matplotlib.py +3 -6
  9. icplot-0.1.7/src/icplot/graph/mpl.py +216 -0
  10. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/plot.py +37 -2
  11. icplot-0.1.7/src/icplot/graph/plot_group.py +63 -0
  12. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/series.py +7 -0
  13. icplot-0.1.7/src/icplot/graph/video.py +35 -0
  14. icplot-0.1.7/src/icplot/graph/vtk.py +73 -0
  15. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/image_utils.py +35 -4
  16. icplot-0.1.7/src/icplot/mesh/__init__.py +1 -0
  17. icplot-0.1.7/src/icplot/mesh/foam.py +140 -0
  18. icplot-0.1.7/src/icplot/mesh/mesh.py +274 -0
  19. icplot-0.1.7/src/icplot/mesh/vtk.py +44 -0
  20. icplot-0.1.5/src/icplot/geometry.py → icplot-0.1.7/src/icplot/scene.py +25 -1
  21. {icplot-0.1.5 → icplot-0.1.7/src/icplot.egg-info}/PKG-INFO +5 -2
  22. {icplot-0.1.5 → icplot-0.1.7}/src/icplot.egg-info/SOURCES.txt +11 -0
  23. {icplot-0.1.5 → icplot-0.1.7}/src/icplot.egg-info/requires.txt +4 -1
  24. {icplot-0.1.5 → icplot-0.1.7}/test/test_cairo_interface.py +1 -1
  25. icplot-0.1.7/test/test_geometry.py +69 -0
  26. {icplot-0.1.5 → icplot-0.1.7}/test/test_line_plots.py +1 -1
  27. icplot-0.1.7/test/test_mesh.py +36 -0
  28. icplot-0.1.5/pyproject.toml +0 -108
  29. {icplot-0.1.5 → icplot-0.1.7}/LICENSE +0 -0
  30. {icplot-0.1.5 → icplot-0.1.7}/README.md +0 -0
  31. {icplot-0.1.5 → icplot-0.1.7}/setup.cfg +0 -0
  32. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/__init__.py +0 -0
  33. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/color.py +0 -0
  34. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/convert.py +0 -0
  35. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/gantt/__init__.py +0 -0
  36. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/gantt/pgfgantt.py +0 -0
  37. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/__init__.py +0 -0
  38. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/graph/generator.py +0 -0
  39. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/main_cli.py +0 -0
  40. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/mermaid.py +0 -0
  41. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/py.typed +0 -0
  42. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/tex.py +0 -0
  43. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/trace/__init__.py +0 -0
  44. {icplot-0.1.5 → icplot-0.1.7}/src/icplot/trace/trace.py +0 -0
  45. {icplot-0.1.5 → icplot-0.1.7}/src/icplot.egg-info/dependency_links.txt +0 -0
  46. {icplot-0.1.5 → icplot-0.1.7}/src/icplot.egg-info/entry_points.txt +0 -0
  47. {icplot-0.1.5 → icplot-0.1.7}/src/icplot.egg-info/top_level.txt +0 -0
  48. {icplot-0.1.5 → icplot-0.1.7}/test/test_gantt_chart.py +0 -0
  49. {icplot-0.1.5 → icplot-0.1.7}/test/test_image_utils.py +0 -0
  50. {icplot-0.1.5 → icplot-0.1.7}/test/test_mermaid.py +0 -0
  51. {icplot-0.1.5 → icplot-0.1.7}/test/test_tex.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: icplot
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Utilities for generating plots and graphics for technical reports.
5
5
  Author-email: "James Grogan, Irish Centre for High End Computing" <james.grogan@ichec.ie>
6
6
  Project-URL: Repository, https://git.ichec.ie/performance/toolshed/icplot
@@ -16,7 +16,10 @@ Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: wand
18
18
  Requires-Dist: matplotlib
19
- Requires-Dist: iccore==0.1.9
19
+ Requires-Dist: vtk
20
+ Requires-Dist: moviepy
21
+ Requires-Dist: scipy
22
+ Requires-Dist: iccore==0.1.13
20
23
  Provides-Extra: test
21
24
  Requires-Dist: pytest; extra == "test"
22
25
  Requires-Dist: pytest-cov; extra == "test"
@@ -0,0 +1,155 @@
1
+ [project]
2
+ name = "icplot"
3
+ version = "0.1.7"
4
+ authors = [
5
+ { name="James Grogan, Irish Centre for High End Computing", email="james.grogan@ichec.ie" },
6
+ ]
7
+ description = "Utilities for generating plots and graphics for technical reports."
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
14
+ "Operating System :: OS Independent",
15
+ "Topic :: System :: Distributed Computing"
16
+ ]
17
+ keywords = ["Publishing", "Technical Reports", "Graphics"]
18
+
19
+ dependencies = ["wand",
20
+ "matplotlib",
21
+ "vtk",
22
+ "moviepy",
23
+ "scipy",
24
+ "iccore==0.1.13"]
25
+
26
+ [project.urls]
27
+ Repository = "https://git.ichec.ie/performance/toolshed/icplot"
28
+ Homepage = "https://git.ichec.ie/performance/toolshed/icplot"
29
+
30
+ [project.optional-dependencies]
31
+ test = [
32
+ "pytest",
33
+ "pytest-cov",
34
+ "pytest-sugar",
35
+ "black",
36
+ "mypy",
37
+ "flake8",
38
+ "pylint"
39
+ ]
40
+
41
+ cairo = [
42
+ "pycairo",
43
+ "CairoSVG"
44
+ ]
45
+
46
+ [build-system]
47
+ requires = ["setuptools", "wheel"]
48
+ build-backend = "setuptools.build_meta"
49
+
50
+ [project.scripts]
51
+ icplot = "icplot.main_cli:main_cli"
52
+
53
+ [tool.setuptools.package-data]
54
+ "icplot" = ["py.typed", "controlDict"]
55
+
56
+ [tool.setuptools.packages.find]
57
+ where = ["src"]
58
+
59
+ [tool.mypy]
60
+ ignore_missing_imports = true
61
+
62
+ [tool.pylint.main]
63
+
64
+ source-roots = ["src"]
65
+ fail-under = 9.2
66
+
67
+ [tool.pylint.basic]
68
+
69
+ class-const-naming-style = "UPPER_CASE"
70
+ class-naming-style = "PascalCase"
71
+ const-naming-style = "UPPER_CASE"
72
+ docstring-min-length = -1
73
+
74
+ function-naming-style = "snake_case"
75
+ method-naming-style = "snake_case"
76
+ module-naming-style = "snake_case"
77
+ variable-naming-style = "snake_case"
78
+
79
+ no-docstring-rgx = "^_"
80
+
81
+ [tool.pylint.design]
82
+ # Maximum number of arguments for function / method.
83
+ max-args = 6
84
+ # Maximum number of attributes for a class (see R0902).
85
+ max-attributes = 7
86
+ # Minimum number of public methods for a class (see R0903).
87
+ min-public-methods = 1
88
+
89
+ [tool.pylint.logging]
90
+ # The type of string formatting that logging methods do. `old` means using %
91
+ # formatting, `new` is for `{}` formatting.
92
+ logging-format-style = "old"
93
+
94
+ # Disable the message, report, category or checker with the given id(s). You can
95
+ # either give multiple identifiers separated by comma (,) or put this option
96
+ # multiple times (only on the command line, not in the configuration file where
97
+ # it should appear only once). You can also use "--disable=all" to disable
98
+ # everything first and then re-enable specific checks. For example, if you want
99
+ # to run only the similarities checker, you can use "--disable=all
100
+ # --enable=similarities". If you want to run only the classes checker, but have
101
+ # no Warning level messages displayed, use "--disable=all --enable=classes
102
+ # --disable=W".
103
+ disable = ["raw-checker-failed", "missing-module-docstring", "missing-function-docstring", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-zero"]
104
+
105
+ [tool.pytest.ini_options]
106
+ testpaths = ["test",]
107
+ log_cli = 1
108
+ log_cli_level = "debug"
109
+ addopts = "--cov=icplot --cov-report term --cov-fail-under=59 --cov-report xml:coverage.xml --cov-report html"
110
+
111
+ [tool.tox]
112
+ requires = ["tox"]
113
+ env_list = ["format_check", "lint", "style", "type", "3.10", "3.13", "docs"]
114
+ skip_missing_interpreters = true
115
+
116
+ [tool.tox.env_run_base]
117
+ description = "Run Unit and Integration Tests"
118
+ deps = [".[test, cairo]"]
119
+ commands = [["pytest", "test"]]
120
+
121
+ [tool.tox.env.format_apply]
122
+ description = "Run Format Apply"
123
+ skip_install = true
124
+ deps = ["black"]
125
+ commands = [["black", "src"], ["black", "test"]]
126
+
127
+ [tool.tox.env.format_check]
128
+ description = "Run Format Check"
129
+ skip_install = true
130
+ deps = ["black"]
131
+ commands = [
132
+ ["black", "--check", "--diff", "src"],
133
+ ["black", "--check", "--diff", "test"]]
134
+
135
+ [tool.tox.env.lint]
136
+ description = "Run Linter"
137
+ deps = ["pylint"]
138
+ commands = [["pylint", "src"]]
139
+
140
+ [tool.tox.env.style]
141
+ description = "Run Style Check"
142
+ skip_install = true
143
+ deps = ["flake8"]
144
+ commands = [["flake8", "src"]]
145
+
146
+ [tool.tox.env.type]
147
+ description = "Run Type Checks"
148
+ deps = ["mypy", ".[types]"]
149
+ commands = [["mypy", "src"]]
150
+
151
+ [tool.tox.env.docs]
152
+ description = "Generate Documentation"
153
+ changedir = "docs"
154
+ deps = ["sphinx"]
155
+ commands = [["sphinx-build","-W", "-b","html","-d","{envtmpdir}/doctrees","."," {envtmpdir}/html"]]
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  import cairo
4
4
 
5
- from .geometry import Scene, Rectangle
5
+ from .scene import Scene, Rectangle
6
6
 
7
7
 
8
8
  def draw_rect(cr, rect: Rectangle):
@@ -1,3 +1,7 @@
1
+ """
2
+ This module represents a gantt chard
3
+ """
4
+
1
5
  from datetime import datetime
2
6
 
3
7
  from pydantic import BaseModel
@@ -8,6 +12,9 @@ from icplot.color import Color
8
12
 
9
13
 
10
14
  class GanttChart(BaseModel, frozen=True):
15
+ """
16
+ A gantt chart
17
+ """
11
18
 
12
19
  milestones: list[Milestone] = []
13
20
  title: str = ""
@@ -1,5 +1,5 @@
1
1
  from iccore.project import Milestone
2
- from icplot.geometry import Scene, SceneItem, Rectangle
2
+ from icplot.scene import Scene, SceneItem, Rectangle
3
3
 
4
4
  from .gantt import GanttChart
5
5
 
@@ -25,10 +25,11 @@ def _render_milestone(
25
25
  return rect
26
26
 
27
27
 
28
- def _render_milestones(gantt: GanttChart):
28
+ def _render_milestones(gantt: GanttChart) -> list[SceneItem]:
29
29
 
30
+ scene_items: list = []
30
31
  if not gantt.milestones:
31
- return
32
+ return scene_items
32
33
 
33
34
  milestones = gantt.milestones
34
35
  # milestones.sort(key=lambda x: x.start_date, reverse=True)
@@ -44,7 +45,7 @@ def _render_milestones(gantt: GanttChart):
44
45
  chart_range = (start_date, end_date)
45
46
  yloc = 0.0
46
47
  bar_height = gantt.bar_max_height * gantt.height
47
- scene_items = []
48
+
48
49
  for milestone in milestones:
49
50
  item = _render_milestone(gantt, milestone, chart_range, yloc)
50
51
  scene_items.append(item)
@@ -0,0 +1,209 @@
1
+ """
2
+ Module for primitive geometries. This is used to keep dependencies simple,
3
+ consider a real geometry library like cgal for more complex or performance
4
+ dependent work.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+ from scipy.spatial.transform import Rotation
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Vector:
16
+ """
17
+ A 3d spatial vector
18
+ """
19
+
20
+ x: float
21
+ y: float = 0.0
22
+ z: float = 0.0
23
+
24
+ def scale(self, factor: float) -> Vector:
25
+ return Vector(self.x * factor, self.y * factor, self.z * factor)
26
+
27
+ def as_array(self) -> np.ndarray:
28
+ return np.array([self.x, self.y, self.z])
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Point:
33
+ """
34
+ A location in 3d space
35
+ """
36
+
37
+ x: float
38
+ y: float = 0.0
39
+ z: float = 0.0
40
+
41
+ def as_array(self) -> np.ndarray:
42
+ return np.array([self.x, self.y, self.z])
43
+
44
+ def translate(self, v: Vector) -> Point:
45
+ return Point(self.x + v.x, self.y + v.y, self.z + v.z)
46
+
47
+ @staticmethod
48
+ def from_array(arr: np.ndarray) -> Point:
49
+ return Point(arr[0], arr[1], arr[2])
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Shape:
54
+ """
55
+ A shape in 3d space, defaults to having a normal to Z
56
+ """
57
+
58
+ loc: Point = Point(0.0, 0.0, 0.0)
59
+ normal: Vector = Vector(0.0, 0.0, 1.0)
60
+
61
+
62
+ def get_rotation_matrix(vec0, vec1):
63
+ """get rotation matrix between two vectors using scipy"""
64
+ vec0 = np.reshape(vec0, (1, -1))
65
+ vec1 = np.reshape(vec1, (1, -1))
66
+ r = Rotation.align_vectors(vec0, vec1)
67
+ return r[0].as_matrix()
68
+
69
+
70
+ def get_normal(p0, p1, p2):
71
+ direction = np.cross(p1 - p0, p2 - p0)
72
+ return direction / np.linalg.norm(direction)
73
+
74
+
75
+ _GLOBAL_Z = np.array([0.0, 0.0, 1.0])
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Quad(Shape):
80
+ """
81
+ A regular quadrilateral in 3d space, defaults to be normal to Z
82
+ """
83
+
84
+ width: float = 1.0
85
+ height: float = 1.0
86
+
87
+ @property
88
+ def points(self) -> list[Point]:
89
+
90
+ points = [
91
+ self.loc.translate(Vector(-self.width / 2.0, -self.height / 2.0)),
92
+ self.loc.translate(Vector(self.width / 2.0, -self.height / 2.0)),
93
+ self.loc.translate(Vector(self.width / 2.0, self.height / 2.0)),
94
+ self.loc.translate(Vector(-self.width / 2.0, self.height / 2.0)),
95
+ ]
96
+
97
+ rot = get_rotation_matrix(_GLOBAL_Z, self.normal.as_array())
98
+ rotated_points = [
99
+ Point.from_array(
100
+ rot.dot(p.as_array() - self.loc.as_array()) + self.loc.as_array()
101
+ )
102
+ for p in points
103
+ ]
104
+
105
+ plane_normal = get_normal(
106
+ rotated_points[0].as_array(),
107
+ rotated_points[1].as_array(),
108
+ rotated_points[3].as_array(),
109
+ )
110
+
111
+ if np.dot(plane_normal, self.normal.as_array()) < 0:
112
+ rotated_points.reverse()
113
+ return rotated_points
114
+
115
+ def translate(self, v: Vector) -> Quad:
116
+ return Quad(self.loc.translate(v), self.normal, self.width, self.height)
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Cuboid(Shape):
121
+ """
122
+ A regular cuboid
123
+ """
124
+
125
+ width: float = 1.0
126
+ height: float = 1.0
127
+ depth: float = 1.0
128
+ top_width_scale: float = 1.0
129
+
130
+ def translate(self, v: Vector) -> Cuboid:
131
+ return Cuboid(
132
+ self.loc.translate(v), self.normal, self.width, self.height, self.depth
133
+ )
134
+
135
+ @property
136
+ def points(self) -> list[Point]:
137
+
138
+ base = Quad(
139
+ self.loc.translate(Vector(0.0, 0.0, -self.depth / 2.0)),
140
+ self.normal,
141
+ self.width,
142
+ self.height,
143
+ )
144
+
145
+ top = Quad(
146
+ self.loc.translate(Vector(0.0, 0.0, -self.depth / 2.0)),
147
+ self.normal,
148
+ self.width * self.top_width_scale,
149
+ self.height,
150
+ )
151
+ top = top.translate(self.normal.scale(self.depth))
152
+ return base.points + top.points
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Cylinder(Shape):
157
+ """
158
+ A cylinder
159
+ """
160
+
161
+ diameter: float = 1.0
162
+ length: float = 1.0
163
+
164
+ @property
165
+ def start(self) -> Point:
166
+ return self.loc
167
+
168
+ @property
169
+ def end(self) -> Point:
170
+ return self.loc.translate(self.normal.scale(self.length))
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class Revolution(Shape):
175
+ """
176
+ A revolved profile sitting on the plane given by the loc
177
+ and normal and revolved about the normal.
178
+ """
179
+
180
+ diameter: float = 1.0
181
+ length: float = 1.0
182
+ profile: str = "arc"
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class CuboidGrid:
187
+ """
188
+ A irregular grid composed of cuboids. Can be useful for
189
+ generating topological meshes like OpenFoam's blockMesh.
190
+ """
191
+
192
+ x_locs: list[float]
193
+ y_locs: list[float]
194
+ z_locs: list[float]
195
+
196
+ @property
197
+ def cuboids(self) -> list[Cuboid]:
198
+ ret = []
199
+
200
+ for kdx, z in enumerate(self.z_locs[:-1]):
201
+ for jdx, y in enumerate(self.y_locs[:-1]):
202
+ for idx, x in enumerate(self.x_locs[:-1]):
203
+ width = self.x_locs[idx + 1] - x
204
+ height = self.y_locs[jdx + 1] - y
205
+ depth = self.z_locs[kdx + 1] - z
206
+ ret.append(
207
+ Cuboid(Point(x, y, z), width=width, height=height, depth=depth)
208
+ )
209
+ return ret
@@ -28,7 +28,7 @@ class PlotAxis(BaseModel, frozen=True):
28
28
  """
29
29
 
30
30
  label: str = ""
31
- ticks: Range | None = None
31
+ ticks: Range | list[int] | None = None
32
32
  scale: str = "linear"
33
33
  text_fontsize: int = 10
34
34
  tick_fontsize: int = 10
@@ -38,5 +38,7 @@ class PlotAxis(BaseModel, frozen=True):
38
38
  @property
39
39
  def resolved_ticks(self) -> list:
40
40
  if self.ticks:
41
- return self.ticks.eval()
41
+ if isinstance(self.ticks, Range):
42
+ return self.ticks.eval()
43
+ return self.ticks
42
44
  return []
@@ -31,10 +31,7 @@ def str_exact_values_formatter(x, pos):
31
31
  then when using plain formatter values less than 1 come out as 0. A way around
32
32
  this is this custom formatter.
33
33
  """
34
- if x.is_integer():
35
- return str(int(x))
36
- else:
37
- return str(x)
34
+ return str(int(x)) if x.is_integer else str(x)
38
35
 
39
36
 
40
37
  def _set_legend_decorations(axs, plot):
@@ -80,9 +77,9 @@ def _set_y_axes_decorations(y_axes: list[PlotAxis], axs):
80
77
  if y_axis.max_ticks:
81
78
  axs[idx].yaxis.set_major_locator(plt.MaxNLocator(y_axis.max_ticks))
82
79
  for key, item in y_axis.limits.items():
83
- if key == "bottom" or key == "ymin":
80
+ if key in ("bottom", "ymin"):
84
81
  axs[idx].set_ylim(ymin=item)
85
- elif key == "top" or key == "ymax":
82
+ elif key in ("top", "ymax"):
86
83
  axs[idx].set_ylim(ymax=item)
87
84
 
88
85
 
@@ -0,0 +1,216 @@
1
+ """
2
+ Module to support plotting with matplotlib
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+
9
+ import numpy as np
10
+ import matplotlib.pyplot as plt
11
+
12
+ from iccore.data.quantity import Quantity
13
+ from iccore.data.units import DateRange, to_date_str
14
+ from iccore.data import Series, Array
15
+
16
+ from .plot_group import PlotGroup
17
+ from .video import images_to_video
18
+
19
+
20
+ def get_axis_label_from_quantity(quantity: Quantity) -> str:
21
+
22
+ if quantity.name == "time":
23
+ return f"{quantity.long_name} (UTC)"
24
+
25
+ if quantity.unit.get_symbol():
26
+ return f"{quantity.long_name} ({quantity.unit.get_symbol()})"
27
+
28
+ return quantity.long_name
29
+
30
+
31
+ def get_axis_label(array: Array) -> str:
32
+ return get_axis_label_from_quantity(array.quantity)
33
+
34
+
35
+ def get_grid(series: Series):
36
+
37
+ if not series.x or not series.y:
38
+ raise RuntimeError("Series missing expected data")
39
+
40
+ return np.meshgrid(series.x.data, series.y.data)
41
+
42
+
43
+ def save_plot(fig, path: Path, filename: str, file_prefix: str = ""):
44
+ plt.tight_layout()
45
+ prefix = f"{file_prefix}_" if file_prefix else ""
46
+ os.makedirs(path, exist_ok=True)
47
+ plt.savefig(path / f"{prefix}{filename}")
48
+ plt.clf()
49
+ plt.close(fig)
50
+
51
+
52
+ def get_date_suffix(dates: DateRange | None) -> str:
53
+ if not dates:
54
+ return ""
55
+ if not dates.start or not dates.end:
56
+ return ""
57
+
58
+ return f"_FROM_{to_date_str(dates.start)}_TO_{to_date_str(dates.end)}"
59
+
60
+
61
+ def plot_2d(
62
+ path: Path, config: PlotGroup, series: Series, quantity: str, file_prefix: str = ""
63
+ ):
64
+ """
65
+ Generate a contour plot for the provided quantity
66
+ """
67
+
68
+ if not series.x:
69
+ raise RuntimeError("Expected series with an x quantity")
70
+
71
+ if not series.y:
72
+ raise RuntimeError("Expected series with a y quantity.")
73
+
74
+ x, y = get_grid(series)
75
+
76
+ array = series.get_array(quantity)
77
+
78
+ fig, ax = plt.subplots()
79
+
80
+ ax.set_ylabel(get_axis_label(series.y))
81
+ ax.set_xlabel(get_axis_label(series.x))
82
+
83
+ if config.date_range:
84
+ start, end = config.date_range.as_tuple()
85
+ if start and end:
86
+ ax.set_xlim((start, end)) # type: ignore
87
+ fig.autofmt_xdate()
88
+
89
+ if config.contour.show_grid:
90
+ ax.grid(c="k", ls="-", alpha=0.3)
91
+
92
+ if False and array.quantity.has_limits:
93
+ cs = ax.contourf(
94
+ x,
95
+ y,
96
+ np.clip(array.data.T, array.quantity.limits[0], array.quantity.limits[1]),
97
+ cmap=config.contour.colormap,
98
+ )
99
+ else:
100
+ cs = ax.contourf(x, y, array.data.T, cmap=config.contour.colormap)
101
+
102
+ cbar = fig.colorbar(cs)
103
+ cbar.ax.set_ylabel(get_axis_label(array))
104
+
105
+ save_plot(
106
+ fig,
107
+ path,
108
+ f"{quantity}{get_date_suffix(config.date_range)}.{config.contour.format}",
109
+ file_prefix,
110
+ )
111
+
112
+
113
+ def plot_1d(
114
+ path: Path, config: PlotGroup, series: Series, quantity: str, file_prefix: str = ""
115
+ ):
116
+ """
117
+ Generate a line plot for the provided quantity
118
+ """
119
+
120
+ if not series.x:
121
+ raise RuntimeError("Attempted to plot series without x data")
122
+
123
+ array = series.get_array(quantity)
124
+
125
+ fig, ax = plt.subplots()
126
+ ax.set_xlabel(get_axis_label(series.x))
127
+ ax.set_ylabel(get_axis_label(array))
128
+
129
+ if config.date_range:
130
+ ax.set_xlim(config.date_range.as_tuple()) # type: ignore
131
+ fig.autofmt_xdate()
132
+
133
+ if array.quantity.has_limits:
134
+ ax.set_ylim(array.quantity.limits) # type: ignore
135
+
136
+ x = array.data.index.to_numpy(dtype="datetime64[ns]")
137
+ y = array.data.values
138
+ ax.plot(x, y)
139
+
140
+ save_plot(
141
+ fig,
142
+ path,
143
+ f"{quantity}{get_date_suffix(config.date_range)}.{config.line.format}",
144
+ file_prefix,
145
+ )
146
+
147
+
148
+ def plot_array(path: Path, series: Series, array: Array, file_prefix):
149
+ """
150
+ Plot an array for a single series x value. Useful for
151
+ time series video frames.
152
+ """
153
+
154
+ if not series.x or not series.y:
155
+ raise RuntimeError("Attempted to plot series with missing data")
156
+
157
+ for idx, (x, values) in enumerate(zip(series.x.data, array.data)):
158
+ fig, ax = plt.subplots()
159
+
160
+ ax.set_title(x)
161
+ ax.set_xlabel(get_axis_label(array))
162
+ ax.set_ylabel(get_axis_label(series.y))
163
+
164
+ if array.quantity.has_limits:
165
+ ax.set_xlim(array.quantity.limits) # type: ignore
166
+
167
+ ax.plot(values, series.y.data)
168
+
169
+ prefix = f"{file_prefix}_" if file_prefix else ""
170
+ save_plot(fig, path / f"{prefix}{array.quantity.name}", f"{idx}.png")
171
+
172
+
173
+ def make_video(
174
+ path: Path, config: PlotGroup, series: Series, quantity: str, file_prefix: str = ""
175
+ ):
176
+ plot_array(path, series, series.get_array(quantity), file_prefix)
177
+
178
+ prefix = f"{file_prefix}_" if file_prefix else ""
179
+
180
+ if not config.video:
181
+ return
182
+
183
+ images_to_video(
184
+ path / f"{prefix}{quantity}",
185
+ path,
186
+ f"{prefix}{quantity}{get_date_suffix(config.date_range)}",
187
+ fps=config.video.fps,
188
+ video_format=config.video.format,
189
+ )
190
+ shutil.rmtree(path / f"{prefix}{quantity}")
191
+
192
+
193
+ def plot(
194
+ path: Path,
195
+ config: PlotGroup,
196
+ series: Series,
197
+ quantity: Quantity,
198
+ file_prefix: str = "",
199
+ ):
200
+ """
201
+ Handle plotting 1-d and 2-d time series and videos for the given quantity
202
+ """
203
+
204
+ if series.is_compound:
205
+ for c in series.components:
206
+ plot(path, config, c, quantity, c.name)
207
+ return
208
+
209
+ if series.y:
210
+ plot_2d(path, config, series, quantity.name, file_prefix)
211
+
212
+ if config.video and config.video.active:
213
+ make_video(path, config, series, quantity.name, file_prefix)
214
+ return
215
+
216
+ plot_1d(path, config, series, quantity.name, file_prefix)