controlchartspy 0.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,13 @@
1
+ """Calculate and visualise statistical process control charts and funnel plots."""
2
+
3
+ from ._api import funnel, funnel_default_settings, spc, spc_default_settings
4
+ from ._result import ControlChart, StaticPlot
5
+
6
+ __all__ = [
7
+ "ControlChart",
8
+ "StaticPlot",
9
+ "funnel",
10
+ "funnel_default_settings",
11
+ "spc",
12
+ "spc_default_settings",
13
+ ]
@@ -0,0 +1,237 @@
1
+ """Public chart constructors mirroring the R package."""
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from typing import Any
5
+
6
+ from ._data import (
7
+ TOOLTIPS,
8
+ Column,
9
+ Columns,
10
+ Data,
11
+ Settings,
12
+ apply_padding,
13
+ prepare_aggregations,
14
+ prepare_data,
15
+ prepare_settings,
16
+ prepare_title,
17
+ )
18
+ from ._engine import engine, render
19
+ from ._limits import funnel_limits, funnel_lines, spc_limits
20
+ from ._result import ControlChart, StaticPlot, dimension
21
+
22
+ Title = str | Mapping[str, object] | None
23
+ ReturnObjs = str | Iterable[str]
24
+
25
+
26
+ def _defaults(chart_type: str, group: str | None) -> dict[str, Any]:
27
+ settings = engine().defaults(chart_type)
28
+ settings["tooltips"] = TOOLTIPS.copy()
29
+ if group is None:
30
+ return settings
31
+ if group not in settings:
32
+ raise ValueError(f"'{group}' is not a valid settings group.")
33
+ group_settings: dict[str, Any] = settings[group]
34
+ return group_settings
35
+
36
+
37
+ def spc_default_settings(group: str | None = None) -> dict[str, Any]:
38
+ """Return the default settings for SPC charts, optionally for one group."""
39
+ return _defaults("spc", group)
40
+
41
+
42
+ def funnel_default_settings(group: str | None = None) -> dict[str, Any]:
43
+ """Return the default settings for funnel plots, optionally for one group."""
44
+ return _defaults("funnel", group)
45
+
46
+
47
+ def _create(
48
+ chart_type: str,
49
+ data: Data,
50
+ keys: Column,
51
+ numerators: Column,
52
+ optional: Mapping[str, Column | None],
53
+ indicators: Columns | None,
54
+ tooltips: Columns | None,
55
+ aggregations: Mapping[str, str] | None,
56
+ title: Title,
57
+ settings: Mapping[str, Settings | None],
58
+ tooltip_settings: Settings | None,
59
+ width: float | None,
60
+ height: float | None,
61
+ return_objs: ReturnObjs,
62
+ ) -> ControlChart:
63
+ if isinstance(return_objs, str):
64
+ return_objs = (return_objs,)
65
+ outputs = set(return_objs)
66
+ valid = {"static_plot", "limits"}
67
+ if chart_type == "funnel":
68
+ valid.add("limit_lines")
69
+ if outputs - valid:
70
+ raise ValueError(f"Invalid return_objs: {', '.join(sorted(outputs - valid))}.")
71
+ width = dimension(width, 640)
72
+ height = dimension(height, 400)
73
+ if tooltip_settings is not None:
74
+ if not isinstance(tooltip_settings, Mapping):
75
+ raise TypeError("tooltip_settings must be a mapping.")
76
+ if set(tooltip_settings) - TOOLTIPS.keys():
77
+ raise ValueError("Invalid tooltip setting.")
78
+ raw_data, order = prepare_data(
79
+ data, keys, numerators, optional, indicators, tooltips, chart_type == "spc"
80
+ )
81
+ if "static_plot" in outputs and raw_data.get("indicators"):
82
+ groups = set(zip(*raw_data["indicators"].values()))
83
+ if len(groups) > 1:
84
+ raise ValueError(
85
+ "Grouped SPC charts support limits only; use return_objs='limits'."
86
+ )
87
+ defaults = _defaults(chart_type, None)
88
+ input_settings, conditional = prepare_settings(settings, defaults, order)
89
+ title_settings = prepare_title(title)
90
+ updates = engine().call(
91
+ "makeUpdateValues",
92
+ raw_data,
93
+ input_settings,
94
+ prepare_aggregations(aggregations),
95
+ conditional,
96
+ list(dict.fromkeys(raw_data["categories"])),
97
+ )
98
+ views = updates["dataViews"]
99
+ apply_padding(chart_type, views, defaults, title_settings)
100
+ result = ControlChart()
101
+ if not outputs:
102
+ return result
103
+ raw = render(
104
+ chart_type,
105
+ views,
106
+ title_settings,
107
+ width,
108
+ height,
109
+ "static_plot" in outputs,
110
+ bool(outputs & {"limits", "limit_lines"}),
111
+ )
112
+ if "static_plot" in outputs:
113
+ result.static_plot = StaticPlot(
114
+ chart_type, raw["svg"], width, height, views, title_settings
115
+ )
116
+ if chart_type == "funnel" and outputs & {"limits", "limit_lines"}:
117
+ lines = funnel_lines(raw, settings.get("lines") or {})
118
+ if "limit_lines" in outputs:
119
+ result.limit_lines = lines
120
+ if "limits" in outputs:
121
+ result.limits = funnel_limits(raw, lines, settings.get("outliers") or {})
122
+ elif chart_type == "spc" and "limits" in outputs:
123
+ result.limits = spc_limits(raw, settings.get("outliers") or {})
124
+ result.indicator_names = raw["indicatorVarNames"]
125
+ return result
126
+
127
+
128
+ def spc(
129
+ data: Data,
130
+ keys: Column,
131
+ numerators: Column,
132
+ denominators: Column | None = None,
133
+ groupings: Column | None = None,
134
+ indicators: Columns | None = None,
135
+ xbar_sds: Column | None = None,
136
+ tooltips: Columns | None = None,
137
+ labels: Column | None = None,
138
+ aggregations: Mapping[str, str] | None = None,
139
+ title: Title = None,
140
+ canvas_settings: Settings | None = None,
141
+ spc_settings: Settings | None = None,
142
+ outlier_settings: Settings | None = None,
143
+ nhs_icon_settings: Settings | None = None,
144
+ scatter_settings: Settings | None = None,
145
+ line_settings: Settings | None = None,
146
+ x_axis_settings: Settings | None = None,
147
+ y_axis_settings: Settings | None = None,
148
+ date_settings: Settings | None = None,
149
+ label_settings: Settings | None = None,
150
+ tooltip_settings: Settings | None = None,
151
+ width: float | None = None,
152
+ height: float | None = None,
153
+ return_objs: ReturnObjs = ("static_plot", "limits"),
154
+ ) -> ControlChart:
155
+ """Generate a statistical process control (SPC) chart and calculate control limits."""
156
+ return _create(
157
+ "spc",
158
+ data,
159
+ keys,
160
+ numerators,
161
+ {
162
+ "denominators": denominators,
163
+ "groupings": groupings,
164
+ "xbar_sds": xbar_sds,
165
+ "labels": labels,
166
+ },
167
+ indicators,
168
+ tooltips,
169
+ aggregations,
170
+ title,
171
+ {
172
+ "canvas": canvas_settings,
173
+ "spc": spc_settings,
174
+ "outliers": outlier_settings,
175
+ "nhs_icons": nhs_icon_settings,
176
+ "scatter": scatter_settings,
177
+ "lines": line_settings,
178
+ "x_axis": x_axis_settings,
179
+ "y_axis": y_axis_settings,
180
+ "dates": date_settings,
181
+ "labels": label_settings,
182
+ },
183
+ tooltip_settings,
184
+ width,
185
+ height,
186
+ return_objs,
187
+ )
188
+
189
+
190
+ def funnel(
191
+ data: Data,
192
+ keys: Column,
193
+ numerators: Column,
194
+ denominators: Column,
195
+ tooltips: Columns | None = None,
196
+ labels: Column | None = None,
197
+ aggregations: Mapping[str, str] | None = None,
198
+ title: Title = None,
199
+ canvas_settings: Settings | None = None,
200
+ funnel_settings: Settings | None = None,
201
+ outlier_settings: Settings | None = None,
202
+ scatter_settings: Settings | None = None,
203
+ line_settings: Settings | None = None,
204
+ x_axis_settings: Settings | None = None,
205
+ y_axis_settings: Settings | None = None,
206
+ label_settings: Settings | None = None,
207
+ tooltip_settings: Settings | None = None,
208
+ width: float | None = None,
209
+ height: float | None = None,
210
+ return_objs: ReturnObjs = ("static_plot", "limits"),
211
+ ) -> ControlChart:
212
+ """Generate a funnel plot and calculate control limits."""
213
+ return _create(
214
+ "funnel",
215
+ data,
216
+ keys,
217
+ numerators,
218
+ {"denominators": denominators, "labels": labels},
219
+ None,
220
+ tooltips,
221
+ aggregations,
222
+ title,
223
+ {
224
+ "canvas": canvas_settings,
225
+ "funnel": funnel_settings,
226
+ "outliers": outlier_settings,
227
+ "scatter": scatter_settings,
228
+ "lines": line_settings,
229
+ "x_axis": x_axis_settings,
230
+ "y_axis": y_axis_settings,
231
+ "labels": label_settings,
232
+ },
233
+ tooltip_settings,
234
+ width,
235
+ height,
236
+ return_objs,
237
+ )
@@ -0,0 +1,284 @@
1
+ """Normalize Python columns and R-compatible chart settings."""
2
+
3
+ import math
4
+ from collections.abc import Iterable, Mapping, Sequence
5
+ from datetime import date, datetime
6
+ from html import escape
7
+ from numbers import Integral, Real
8
+ from typing import Any, TypeGuard
9
+
10
+ Scalar = str | bool | int | float | None
11
+ Column = str | Iterable[object]
12
+ Columns = Column | Mapping[str, Column]
13
+ Data = Mapping[str, Column] | Sequence[Mapping[str, object]]
14
+ Settings = Mapping[str, Any]
15
+ Record = dict[str, Any]
16
+
17
+ AGGREGATIONS = {
18
+ "numerators": "sum",
19
+ "denominators": "sum",
20
+ "groupings": "first",
21
+ "xbar_sds": "first",
22
+ "tooltips": "first",
23
+ "labels": "first",
24
+ }
25
+ TOOLTIPS = {
26
+ "ttip_font_size": 12,
27
+ "ttip_font": "Arial, sans-serif",
28
+ "ttip_font_color": "#000000",
29
+ "ttip_background_color": "#E0E0E0",
30
+ "ttip_opacity": 1,
31
+ "ttip_border_radius": 5,
32
+ "ttip_border_color": "#000000",
33
+ "ttip_border_width": 1,
34
+ }
35
+ TITLE: dict[str, Any] = {
36
+ "text": None,
37
+ "font_size": "16px",
38
+ "font_weight": "bold",
39
+ "font_family": "'Arial', sans-serif",
40
+ "x": "50%",
41
+ "y": 5,
42
+ "text_anchor": "middle",
43
+ "dominant_baseline": "hanging",
44
+ "subtitle": None,
45
+ "subtitle_font_size": "12px",
46
+ "subtitle_font_weight": "normal",
47
+ }
48
+
49
+
50
+ def missing(value: object) -> bool:
51
+ return value is None or (isinstance(value, Real) and math.isnan(value))
52
+
53
+
54
+ def is_sequence(value: object) -> TypeGuard[Iterable[object]]:
55
+ return isinstance(value, Iterable) and not isinstance(value, (str, bytes, Mapping))
56
+
57
+
58
+ def scalar(value: object) -> Scalar:
59
+ if missing(value):
60
+ return None
61
+ if isinstance(value, (str, bool)):
62
+ return value
63
+ if isinstance(value, Integral):
64
+ return int(value)
65
+ if isinstance(value, Real):
66
+ if not math.isfinite(value):
67
+ raise ValueError("Infinite values are not supported.")
68
+ return float(value)
69
+ if isinstance(value, (date, datetime)):
70
+ return label(value)
71
+ raise TypeError(f"Unsupported value type: {type(value).__name__}")
72
+
73
+
74
+ def label(value: object) -> str | None:
75
+ if missing(value):
76
+ return None
77
+ if isinstance(value, bool):
78
+ return str(value).upper()
79
+ if isinstance(value, datetime):
80
+ return value.isoformat(sep=" ")
81
+ if isinstance(value, date):
82
+ return value.isoformat()
83
+ if isinstance(value, Real):
84
+ return format(value, ".15g")
85
+ return str(value)
86
+
87
+
88
+ def column(
89
+ data: Data, value: Column, name: str, length: int | None = None
90
+ ) -> list[Any]:
91
+ if isinstance(value, str):
92
+ if isinstance(data, Sequence):
93
+ value = [row[value] for row in data]
94
+ else:
95
+ value = data[value]
96
+ if not is_sequence(value):
97
+ raise TypeError(f"{name} must be a column name or a sequence.")
98
+ result = list(value)
99
+ if length is not None and len(result) != length:
100
+ raise ValueError(f"{name} must have one value per observation ({length}).")
101
+ return result
102
+
103
+
104
+ def named_columns(
105
+ data: Data, value: Columns | None, name: str, length: int
106
+ ) -> dict[str, list[str | None]]:
107
+ if value is None:
108
+ return {}
109
+ columns: Mapping[str, Column]
110
+ if isinstance(value, Mapping):
111
+ columns = value
112
+ elif isinstance(value, str):
113
+ columns = {value: value}
114
+ else:
115
+ columns = {name: value}
116
+ if not columns:
117
+ raise ValueError(f"{name} must contain at least one column.")
118
+ return {
119
+ str(key): [label(item) for item in column(data, values, name, length)]
120
+ for key, values in columns.items()
121
+ }
122
+
123
+
124
+ def prepare_data(
125
+ data: Data,
126
+ keys: Column,
127
+ numerators: Column,
128
+ optional: Mapping[str, Column | None],
129
+ indicators: Columns | None,
130
+ tooltips: Columns | None,
131
+ date_keys: bool,
132
+ ) -> tuple[Record, list[int]]:
133
+ keys = column(data, keys, "keys")
134
+ n = len(keys)
135
+ if n == 0:
136
+ raise ValueError("No data present")
137
+ if date_keys:
138
+ for key in keys:
139
+ if not (missing(key) or isinstance(key, (str, date))):
140
+ raise TypeError("SPC keys must be dates or strings.")
141
+ groups = named_columns(data, indicators, "indicators", n)
142
+ tips = named_columns(data, tooltips, "tooltips", n)
143
+
144
+ def sort_key(index: int) -> tuple[tuple[bool, Any], ...]:
145
+ values: list[Any] = [values[index] for values in groups.values()]
146
+ key = keys[index]
147
+ if date_keys:
148
+ key = label(key)
149
+ values.append(key)
150
+ return tuple((missing(value), value) for value in values)
151
+
152
+ order = sorted(range(n), key=sort_key)
153
+ raw: Record = {
154
+ "crosstalk_identities": [str(i + 1) for i in order],
155
+ "categories": [label(keys[i]) for i in order],
156
+ }
157
+ fields = {"numerators": numerators, **optional}
158
+ for name, value in fields.items():
159
+ if value is None:
160
+ continue
161
+ values = column(data, value, name, n)
162
+ if name in ("groupings", "labels"):
163
+ values = [label(item) for item in values]
164
+ if name == "labels":
165
+ values = [
166
+ escape(item, quote=False) if item is not None else None
167
+ for item in values
168
+ ]
169
+ else:
170
+ values = [scalar(item) for item in values]
171
+ if any(
172
+ item is not None and not isinstance(item, (int, float))
173
+ for item in values
174
+ ):
175
+ raise TypeError(f"{name} must contain numeric values.")
176
+ raw[name] = [values[i] for i in order]
177
+ if groups:
178
+ raw["indicators"] = {
179
+ name: [values[i] for i in order] for name, values in groups.items()
180
+ }
181
+ if tips:
182
+ raw["tooltips"] = {
183
+ name: [values[i] for i in order] for name, values in tips.items()
184
+ }
185
+ return raw, order
186
+
187
+
188
+ def prepare_settings(
189
+ settings: Mapping[str, Settings | None],
190
+ defaults: Mapping[str, Settings],
191
+ order: Sequence[int],
192
+ ) -> tuple[dict[str, dict[str, Any]], bool]:
193
+ result: dict[str, dict[str, Any]] = {}
194
+ conditional = False
195
+ n = len(order)
196
+ for group, entries in settings.items():
197
+ if entries is None:
198
+ continue
199
+ if not isinstance(entries, Mapping):
200
+ raise TypeError(f"{group} settings must be a mapping.")
201
+ result[group] = {}
202
+ for name, value in entries.items():
203
+ if name not in defaults[group]:
204
+ raise ValueError(f"Invalid setting '{name}' in group '{group}'.")
205
+ if is_sequence(value):
206
+ value = list(value)
207
+ if len(value) == 1:
208
+ value = value[0]
209
+ elif len(value) == n:
210
+ conditional = True
211
+ result[group][name] = {
212
+ str(i + 1): setting_value(name, value[i]) for i in order
213
+ }
214
+ continue
215
+ else:
216
+ raise ValueError(f"Setting '{name}' must have one or {n} values.")
217
+ result[group][name] = setting_value(name, value)
218
+ return result, conditional
219
+
220
+
221
+ def setting_value(name: str, value: object) -> Scalar:
222
+ value = scalar(value)
223
+ if name.endswith("_label") and value is not None:
224
+ return escape(str(value), quote=False)
225
+ return value
226
+
227
+
228
+ def prepare_aggregations(aggregations: Mapping[str, str] | None) -> dict[str, str]:
229
+ result = AGGREGATIONS.copy()
230
+ if aggregations is None:
231
+ return result
232
+ if not isinstance(aggregations, Mapping):
233
+ raise TypeError("aggregations must be a mapping.")
234
+ valid = {"first", "last", "sum", "mean", "min", "max", "median", "count"}
235
+ for name, value in aggregations.items():
236
+ if name not in result:
237
+ raise ValueError(f"'{name}' is not a valid variable to aggregate.")
238
+ if value not in valid:
239
+ raise ValueError(f"'{value}' is not a valid aggregation.")
240
+ result[name] = value
241
+ return result
242
+
243
+
244
+ def prepare_title(title: str | Mapping[str, object] | None) -> dict[str, Any]:
245
+ result = TITLE.copy()
246
+ if isinstance(title, str):
247
+ result["text"] = title
248
+ elif title is not None:
249
+ if not isinstance(title, Mapping):
250
+ raise TypeError("title must be a string or a mapping.")
251
+ for name, value in title.items():
252
+ if name not in result:
253
+ raise ValueError(f"Invalid title setting '{name}'.")
254
+ if value is not None:
255
+ result[name] = scalar(value)
256
+ for name in ("text", "subtitle"):
257
+ if result[name] is not None:
258
+ result[name] = escape(str(result[name]), quote=False)
259
+ return result
260
+
261
+
262
+ def apply_padding(
263
+ chart_type: str,
264
+ views: list[Any],
265
+ defaults: Mapping[str, Settings],
266
+ title: Mapping[str, Any],
267
+ ) -> None:
268
+ title_padding = 0.0
269
+ if title["text"] is not None:
270
+ title_padding = float(str(title["font_size"]).removesuffix("px")) + title["y"]
271
+ if title["subtitle"] is not None:
272
+ title_padding += float(str(title["subtitle_font_size"]).removesuffix("px"))
273
+ for settings in views[0]["categorical"]["categories"][0]["objects"]:
274
+ canvas = {**defaults["canvas"], **settings.get("canvas", {})}
275
+ axis = {**defaults["x_axis"], **settings.get("x_axis", {})}
276
+ canvas["upper_padding"] += title_padding
277
+ canvas["left_padding"] += 50
278
+ pad = 10 + axis["xlimit_tick_size"]
279
+ if axis["xlimit_tick_rotation"] != 0:
280
+ pad += 15
281
+ if chart_type == "spc" and axis["xlimit_label"]:
282
+ pad += 20
283
+ canvas["lower_padding"] += pad
284
+ settings["canvas"] = canvas
@@ -0,0 +1,97 @@
1
+ """Thread-local access to the shared chart engine."""
2
+
3
+ import json
4
+ import threading
5
+ import warnings
6
+ from collections.abc import Mapping
7
+ from importlib.resources import files
8
+ from typing import Any
9
+
10
+ import quickjs
11
+
12
+ _STATE = threading.local()
13
+ _SCRIPTS = (
14
+ "minidom.js",
15
+ "ccD3.js",
16
+ "PBISPC.js",
17
+ "PBIFUN.js",
18
+ "MISC.js",
19
+ "commonUtils.js",
20
+ "headlessUtils.js",
21
+ )
22
+
23
+
24
+ def _warn(message: str) -> None:
25
+ warnings.warn(message, RuntimeWarning, stacklevel=3)
26
+
27
+
28
+ class Engine:
29
+ def __init__(self) -> None:
30
+ self.context = quickjs.Context()
31
+ self.context.add_callable("pythonWarning", _warn)
32
+ self.context.eval("""
33
+ globalThis.console = {
34
+ log: (...args) => pythonWarning(args.join(" ")),
35
+ error: (error) => pythonWarning(String(error))
36
+ };
37
+ function pythonReplacer(key, value) {
38
+ if (value === undefined) {
39
+ return null;
40
+ }
41
+ return value;
42
+ }
43
+ function pythonCall(name, payload) {
44
+ return JSON.stringify(globalThis[name](...JSON.parse(payload)), pythonReplacer);
45
+ }
46
+ function pythonDefaults(type) {
47
+ return JSON.stringify(globalThis[type].defaultSettings, pythonReplacer);
48
+ }
49
+ """)
50
+ assets = files("controlchartspy").joinpath("_vendor")
51
+ for name in _SCRIPTS:
52
+ self.context.eval(assets.joinpath(name).read_text(encoding="utf-8"))
53
+ self.context.eval("initialiseHeadless()")
54
+ self._call = self.context.get("pythonCall")
55
+ self._defaults = self.context.get("pythonDefaults")
56
+
57
+ def call(self, name: str, *args: object) -> Any:
58
+ try:
59
+ return json.loads(self._call(name, json.dumps(args, allow_nan=False)))
60
+ except quickjs.JSException as error:
61
+ raise ValueError(str(error)) from error
62
+
63
+ def defaults(self, chart_type: str) -> dict[str, Any]:
64
+ defaults: dict[str, Any] = json.loads(self._defaults(chart_type))
65
+ return defaults
66
+
67
+
68
+ def engine() -> Engine:
69
+ instance: Engine | None = getattr(_STATE, "engine", None)
70
+ if instance is None:
71
+ instance = Engine()
72
+ _STATE.engine = instance
73
+ return instance
74
+
75
+
76
+ def render(
77
+ chart_type: str,
78
+ data_views: list[Any],
79
+ title: Mapping[str, Any],
80
+ width: float,
81
+ height: float,
82
+ static: bool,
83
+ limits: bool,
84
+ ) -> dict[str, Any]:
85
+ result: dict[str, Any] = engine().call(
86
+ "updateHeadlessVisual",
87
+ chart_type,
88
+ data_views,
89
+ title,
90
+ width,
91
+ height,
92
+ static,
93
+ limits,
94
+ )
95
+ if "error" in result:
96
+ raise ValueError(result["error"])
97
+ return result