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,84 @@
1
+ """Convert chart-engine results to R-compatible records."""
2
+
3
+ from typing import Any
4
+
5
+ from ._data import Record, Settings
6
+
7
+
8
+ def spc_rows(rows: list[Record], settings: Settings) -> list[Record]:
9
+ rows = [row.get("table_row", row) for row in rows]
10
+ if not rows:
11
+ return []
12
+ flags = {
13
+ "astronomical": "astpoint",
14
+ "shift": "shift",
15
+ "trend": "trend",
16
+ "two_in_three": "two_in_three",
17
+ }
18
+ excluded = {
19
+ column for flag, column in flags.items() if not settings.get(flag, False)
20
+ }
21
+ columns: list[str] = []
22
+ for row in rows:
23
+ for name, value in row.items():
24
+ if value is not None and name not in excluded and name not in columns:
25
+ columns.append(name)
26
+ result = []
27
+ for row in rows:
28
+ record: Record = {name: row.get(name) for name in columns}
29
+ record["date"] = record["date"].strip()
30
+ result.append(record)
31
+ return result
32
+
33
+
34
+ def spc_limits(raw: Record, settings: Settings) -> list[Record] | dict[str, Any]:
35
+ if not raw["indicatorVarNames"]:
36
+ return spc_rows(raw["plotPoints"], settings)
37
+ result: dict[str, Any] = {}
38
+ for groups, rows in zip(raw["groupNames"], raw["spcLimitRows"]):
39
+ names = []
40
+ for name in groups:
41
+ if name is None or name == "":
42
+ name = "<blank>"
43
+ names.append(str(name))
44
+ branch = result
45
+ for name in names[:-1]:
46
+ branch = branch.setdefault(name, {})
47
+ branch[names[-1]] = spc_rows(rows, settings)
48
+ return result
49
+
50
+
51
+ def funnel_lines(raw: Record, settings: Settings) -> list[Record]:
52
+ result = []
53
+ for row in raw["calculatedLimits"]:
54
+ record = {"denominator": row["denominators"]}
55
+ for name, value in row.items():
56
+ if name == "denominators":
57
+ continue
58
+ if name == "alt_target" and settings.get("alt_target") is None:
59
+ continue
60
+ record[name] = value
61
+ result.append(record)
62
+ return result
63
+
64
+
65
+ def funnel_limits(raw: Record, lines: list[Record], settings: Settings) -> list[Record]:
66
+ by_denominator: dict[Any, list[Record]] = {}
67
+ for row in lines:
68
+ by_denominator.setdefault(row["denominator"], []).append(row)
69
+ result = []
70
+ for point in raw["plotPoints"]:
71
+ for line in by_denominator.get(point["x"], []):
72
+ record = {
73
+ "denominator": point["x"],
74
+ "group": point["group_text"],
75
+ "numerator": point["numerator"],
76
+ "value": point["value"],
77
+ "z": point["z"],
78
+ }
79
+ for flag in ("two_sigma", "three_sigma"):
80
+ if settings.get(flag, False):
81
+ record[flag] = point[flag]
82
+ record.update(line)
83
+ result.append(record)
84
+ return sorted(result, key=lambda row: row["denominator"])
@@ -0,0 +1,98 @@
1
+ """Chart results and standalone SVG export."""
2
+
3
+ import math
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from numbers import Real
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ._data import Record
11
+ from ._engine import render
12
+
13
+
14
+ def dimension(value: object, default: float) -> float:
15
+ if value is None:
16
+ return default
17
+ if isinstance(value, bool) or not isinstance(value, Real):
18
+ raise TypeError("Chart dimensions must be positive numbers.")
19
+ if not math.isfinite(value) or value <= 0:
20
+ raise ValueError("Chart dimensions must be positive numbers.")
21
+ return float(value)
22
+
23
+
24
+ def svg_string(svg: str, width: float, height: float) -> str:
25
+ svg = svg.replace('dominant-baseline="hanging"', 'dy="0.8em"')
26
+ svg = svg.replace('dominant-baseline="middle"', 'dy="0.35em"')
27
+ return (
28
+ f'<svg viewBox="0 0 {width:g} {height:g}" width="{width:g}px" '
29
+ f'height="{height:g}px" xmlns="http://www.w3.org/2000/svg">'
30
+ '<rect x="0" y="0" width="100%" height="100%" fill="white"/>'
31
+ f"{svg}</svg>"
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class StaticPlot:
37
+ """A static SVG plot with its dimensions and rendering settings."""
38
+
39
+ type: str
40
+ svg: str
41
+ width: float
42
+ height: float
43
+ data_views: list[Any] = field(repr=False)
44
+ title_settings: dict[str, Any] = field(repr=False)
45
+
46
+ def to_svg(self, width: float | None = None, height: float | None = None) -> str:
47
+ """Return a complete SVG document, optionally rendered at a new size."""
48
+ svg = self.svg
49
+ if width is not None or height is not None:
50
+ width = dimension(width, self.width)
51
+ height = dimension(height, self.height)
52
+ svg = render(
53
+ self.type,
54
+ self.data_views,
55
+ self.title_settings,
56
+ width,
57
+ height,
58
+ True,
59
+ False,
60
+ )["svg"]
61
+ else:
62
+ width = self.width
63
+ height = self.height
64
+ return svg_string(svg, width, height)
65
+
66
+ def _repr_svg_(self) -> str:
67
+ return self.to_svg()
68
+
69
+
70
+ @dataclass
71
+ class ControlChart:
72
+ """A control chart containing the requested plot and calculated limits."""
73
+
74
+ static_plot: StaticPlot | None = None
75
+ limits: list[Record] | dict[str, Any] | None = None
76
+ limit_lines: list[Record] | None = None
77
+ indicator_names: list[str] = field(default_factory=list)
78
+
79
+ def save_plot(
80
+ self,
81
+ file: str | os.PathLike[str],
82
+ width: float | None = None,
83
+ height: float | None = None,
84
+ ) -> None:
85
+ """Save the static plot as SVG, optionally rendering at a new size."""
86
+ if self.static_plot is None:
87
+ raise ValueError(
88
+ "This object has no static plot; request 'static_plot' in return_objs."
89
+ )
90
+ path = Path(file)
91
+ if path.suffix.lower() != ".svg":
92
+ raise ValueError("Only SVG output is supported.")
93
+ path.write_text(self.static_plot.to_svg(width, height) + "\n", encoding="utf-8")
94
+
95
+ def _repr_svg_(self) -> str | None:
96
+ if self.static_plot is not None:
97
+ return self.static_plot.to_svg()
98
+ return None
@@ -0,0 +1,410 @@
1
+ var misc = (function () {
2
+ "use strict";
3
+
4
+ const defaultSettings = {
5
+ canvas: {
6
+ upper_padding: 80,
7
+ right_padding: 20,
8
+ lower_padding: 45,
9
+ left_padding: 220,
10
+ background_colour: "#FFFFFF",
11
+ font_family: "Arial, sans-serif"
12
+ },
13
+ misc: {
14
+ source: "",
15
+ unfavourable_label: "Unfavourable",
16
+ favourable_label: "Favourable",
17
+ unfavourable_colour: "#E46C0A",
18
+ favourable_colour: "#00B0F0",
19
+ direction_font_size: 12
20
+ },
21
+ bars: {
22
+ colour_deterioration: "#E46C0A",
23
+ colour_none: "#A6A6A6",
24
+ colour_improvement: "#00B0F0",
25
+ height_ratio: 0.65,
26
+ opacity: 1
27
+ },
28
+ lines: {
29
+ colour: "#00667B",
30
+ width: 1.5,
31
+ show_95: true,
32
+ show_99: true,
33
+ show_zero: true,
34
+ lower_95: -1.959963984540054,
35
+ upper_95: 1.959963984540054,
36
+ lower_99: -3.090232306167813,
37
+ upper_99: 3.090232306167813,
38
+ type_95: "2,2",
39
+ type_99: "6,4"
40
+ },
41
+ x_axis: {
42
+ xlimit_l: -5,
43
+ xlimit_u: 5,
44
+ xlimit_tick_interval: 1,
45
+ xlimit_tick_size: 10,
46
+ xlimit_tick_font: "Arial, sans-serif",
47
+ xlimit_colour: "#000000",
48
+ xlimit_grid_show: true,
49
+ xlimit_grid_colour: "#E5E5E5",
50
+ xlimit_label: ""
51
+ },
52
+ y_axis: {
53
+ ylimit_tick_size: 11,
54
+ ylimit_tick_font: "Arial, sans-serif",
55
+ ylimit_colour: "#000000",
56
+ group_tick_size: 11,
57
+ group_font_weight: "bold"
58
+ }
59
+ };
60
+
61
+ function valueColumn(view, role) {
62
+ return (view.values || []).find(column => column.source.roles?.[role]);
63
+ }
64
+
65
+ function tooltipColumns(view) {
66
+ return (view.values || []).filter(column => column.source.roles?.tooltips);
67
+ }
68
+
69
+ function columnValues(column) {
70
+ if (!column) return [];
71
+ return Array.isArray(column.values) ? column.values : [column.values];
72
+ }
73
+
74
+ function mergeSettings(objects) {
75
+ const settings = {};
76
+ Object.keys(defaultSettings).forEach(group => {
77
+ settings[group] = Object.assign({}, defaultSettings[group], objects?.[group] || {});
78
+ });
79
+ return settings;
80
+ }
81
+
82
+ function formatNumber(value, digits) {
83
+ if (value === null || value === undefined || !Number.isFinite(Number(value))) {
84
+ return "";
85
+ }
86
+ return Number(value).toFixed(digits);
87
+ }
88
+
89
+ function pointColour(point) {
90
+ const bars = point.settings.bars;
91
+ if (point.outlier === "improvement") return bars.colour_improvement;
92
+ if (point.outlier === "deterioration") return bars.colour_deterioration;
93
+ return bars.colour_none;
94
+ }
95
+
96
+ function makeTooltip(point, extras) {
97
+ const value = value => `${formatNumber(value, point.sigFigs)}${point.suffix}`;
98
+ const countDigits = point.suffix === "%" ? 0 : point.sigFigs;
99
+ const items = [
100
+ { displayName: "Indicator", value: point.indicator },
101
+ { displayName: "Group", value: point.group },
102
+ { displayName: "Z-score", value: formatNumber(point.score, 3) },
103
+ { displayName: "Numerator", value: formatNumber(point.numerator, countDigits) },
104
+ { displayName: "Denominator", value: formatNumber(point.denominator, countDigits) },
105
+ { displayName: "Actual Value", value: value(point.value) },
106
+ { displayName: "Upper 99.8% Limit", value: value(point.ul99) },
107
+ { displayName: "Centerline", value: value(point.target) },
108
+ { displayName: "Lower 99.8% Limit", value: value(point.ll99) }
109
+ ];
110
+ extras.forEach(extra => items.push(extra));
111
+ return items;
112
+ }
113
+
114
+ class MiscViewModel {
115
+ constructor() {
116
+ this.plotPoints = [];
117
+ this.svgWidth = 0;
118
+ this.svgHeight = 0;
119
+ this.settings = mergeSettings(null);
120
+ }
121
+
122
+ update(options, host) {
123
+ this.svgWidth = options.viewport.width;
124
+ this.svgHeight = options.viewport.height;
125
+ this.plotPoints = [];
126
+ const view = options.dataViews?.[0]?.categorical;
127
+ if (!view || !view.categories?.[0]) {
128
+ return { status: true };
129
+ }
130
+
131
+ const category = view.categories[0];
132
+ const columns = {
133
+ groupings: valueColumn(view, "groupings"),
134
+ groups: valueColumn(view, "groups"),
135
+ scores: valueColumn(view, "scores"),
136
+ zScores: valueColumn(view, "z_scores"),
137
+ outliers: valueColumn(view, "outliers"),
138
+ numerators: valueColumn(view, "numerators"),
139
+ denominators: valueColumn(view, "denominators"),
140
+ values: valueColumn(view, "values"),
141
+ ll99: valueColumn(view, "ll99"),
142
+ targets: valueColumn(view, "targets"),
143
+ ul99: valueColumn(view, "ul99"),
144
+ suffixes: valueColumn(view, "suffixes"),
145
+ sigFigs: valueColumn(view, "sig_figs")
146
+ };
147
+ if (!columns.scores) {
148
+ return { status: false, error: "MISC scores are required." };
149
+ }
150
+
151
+ const extras = tooltipColumns(view);
152
+ const categoryValues = columnValues(category);
153
+ const values = Object.fromEntries(
154
+ Object.entries(columns).map(([name, column]) => [name, columnValues(column)])
155
+ );
156
+ const extraValues = extras.map(columnValues);
157
+ for (let i = 0; i < categoryValues.length; i++) {
158
+ const settings = mergeSettings(category.objects?.[i]);
159
+ const extraItems = extras.map((column, j) => ({
160
+ displayName: column.source.displayName,
161
+ value: extraValues[j][i]
162
+ }));
163
+ const point = {
164
+ indicator: categoryValues[i],
165
+ grouping: values.groupings[i] ?? "",
166
+ group: values.groups[i] ?? "",
167
+ score: Number(values.scores[i]),
168
+ z: Number(values.zScores[i]),
169
+ outlier: values.outliers[i] ?? "none",
170
+ numerator: values.numerators[i],
171
+ denominator: values.denominators[i],
172
+ value: values.values[i],
173
+ ll99: values.ll99[i],
174
+ target: values.targets[i],
175
+ ul99: values.ul99[i],
176
+ suffix: values.suffixes[i] ?? "",
177
+ sigFigs: Number(values.sigFigs[i] ?? 2),
178
+ settings: settings,
179
+ identity: host.createSelectionIdBuilder()
180
+ .withCategory(category, i)
181
+ .createSelectionId()
182
+ };
183
+ point.tooltip = makeTooltip(point, extraItems);
184
+ this.plotPoints.push(point);
185
+ }
186
+ this.settings = this.plotPoints[0]?.settings ?? mergeSettings(null);
187
+ return { status: true };
188
+ }
189
+ }
190
+
191
+ class Visual {
192
+ constructor(options) {
193
+ this.host = options.host;
194
+ this.selectionManager = options.host.createSelectionManager();
195
+ this.svg = ccD3.select(options.element).append("svg");
196
+ this.viewModel = new MiscViewModel();
197
+ }
198
+
199
+ update(options) {
200
+ const status = this.viewModel.update(options, this.host);
201
+ this.svg
202
+ .attr("width", options.viewport.width)
203
+ .attr("height", options.viewport.height)
204
+ .style("background", this.viewModel.settings.canvas.background_colour);
205
+ this.svg.selectAll(".misc-root, .errormessage").remove();
206
+ if (!status.status) {
207
+ this.svg.append("text")
208
+ .classed("errormessage", true)
209
+ .attr("x", 10)
210
+ .attr("y", 20)
211
+ .text(status.error);
212
+ return status;
213
+ }
214
+ if (this.viewModel.plotPoints.length === 0) return status;
215
+ this.draw();
216
+ return status;
217
+ }
218
+
219
+ draw() {
220
+ const points = this.viewModel.plotPoints;
221
+ const settings = this.viewModel.settings;
222
+ const canvas = settings.canvas;
223
+ const axis = settings.x_axis;
224
+ const width = this.viewModel.svgWidth;
225
+ const height = this.viewModel.svgHeight;
226
+ const left = canvas.left_padding;
227
+ const right = width - canvas.right_padding;
228
+ const top = canvas.upper_padding;
229
+ const bottom = height - canvas.lower_padding;
230
+ const plotWidth = Math.max(1, right - left);
231
+ const plotHeight = Math.max(1, bottom - top);
232
+ // Each group heading occupies its own row above its indicators
233
+ const rowIndex = new Array(points.length);
234
+ let rows = 0;
235
+ for (let i = 0; i < points.length; i++) {
236
+ if (points[i].grouping && points[i].grouping !== points[i - 1]?.grouping) rows++;
237
+ rowIndex[i] = rows++;
238
+ }
239
+ const rowHeight = plotHeight / rows;
240
+ const lower = axis.xlimit_l;
241
+ const upper = axis.xlimit_u;
242
+ const scale = value => left + ((value - lower) / (upper - lower)) * plotWidth;
243
+ const root = this.svg.append("g").classed("misc-root", true);
244
+
245
+ if (axis.xlimit_grid_show) {
246
+ for (let tick = lower; tick <= upper; tick += axis.xlimit_tick_interval) {
247
+ root.append("line")
248
+ .classed("misc-grid-line", true)
249
+ .attr("x1", scale(tick)).attr("x2", scale(tick))
250
+ .attr("y1", top).attr("y2", bottom)
251
+ .attr("stroke", axis.xlimit_grid_colour);
252
+ }
253
+ }
254
+
255
+ this.drawControlLines(root, scale, top, bottom, settings.lines);
256
+ root.append("line")
257
+ .classed("misc-axis", true)
258
+ .attr("x1", left).attr("x2", right)
259
+ .attr("y1", bottom).attr("y2", bottom)
260
+ .attr("stroke", axis.xlimit_colour);
261
+
262
+ for (let tick = lower; tick <= upper; tick += axis.xlimit_tick_interval) {
263
+ root.append("text")
264
+ .classed("misc-x-tick", true)
265
+ .attr("x", scale(tick)).attr("y", bottom + axis.xlimit_tick_size + 5)
266
+ .attr("text-anchor", "middle")
267
+ .style("font-family", axis.xlimit_tick_font)
268
+ .style("font-size", `${axis.xlimit_tick_size}px`)
269
+ .style("fill", axis.xlimit_colour)
270
+ .text(tick);
271
+ }
272
+
273
+ const zero = scale(0);
274
+ const bars = root.selectAll(".misc-bar")
275
+ .data(points)
276
+ .join("rect")
277
+ .classed("misc-bar", true)
278
+ .attr("x", point => scale(Math.min(0, Math.max(lower, Math.min(upper, point.score)))))
279
+ .attr("y", (_, i) => top + rowIndex[i] * rowHeight + rowHeight * (1 - settings.bars.height_ratio) / 2)
280
+ .attr("width", point => Math.abs(scale(Math.max(lower, Math.min(upper, point.score))) - zero))
281
+ .attr("height", rowHeight * settings.bars.height_ratio)
282
+ .attr("fill", pointColour)
283
+ .attr("opacity", point => point.settings.bars.opacity);
284
+
285
+ bars.on("mouseover", (event, point) => {
286
+ this.host.tooltipService.show({
287
+ coordinates: [event.offsetX ?? 0, event.offsetY ?? 0],
288
+ dataItems: point.tooltip,
289
+ identities: [point.identity]
290
+ });
291
+ }).on("mouseout", () => this.host.tooltipService.hide());
292
+
293
+ points.forEach((point, i) => {
294
+ const y = top + (rowIndex[i] + 0.5) * rowHeight;
295
+ root.append("text")
296
+ .classed("misc-y-tick", true)
297
+ .attr("x", left - 8).attr("y", y)
298
+ .attr("text-anchor", "end")
299
+ .attr("dominant-baseline", "middle")
300
+ .style("font-family", settings.y_axis.ylimit_tick_font)
301
+ .style("font-size", `${settings.y_axis.ylimit_tick_size}px`)
302
+ .style("fill", settings.y_axis.ylimit_colour)
303
+ .text(point.indicator);
304
+ if (point.grouping && point.grouping !== points[i - 1]?.grouping) {
305
+ root.append("text")
306
+ .classed("misc-group-label", true)
307
+ .attr("x", 5).attr("y", y - rowHeight)
308
+ .attr("dominant-baseline", "middle")
309
+ .style("font-family", settings.y_axis.ylimit_tick_font)
310
+ .style("font-size", `${settings.y_axis.group_tick_size}px`)
311
+ .style("font-weight", settings.y_axis.group_font_weight)
312
+ .style("fill", settings.y_axis.ylimit_colour)
313
+ .text(point.grouping);
314
+ }
315
+ });
316
+
317
+ this.drawHeader(root, scale, top, settings);
318
+ if (axis.xlimit_label) {
319
+ root.append("text")
320
+ .classed("misc-x-label", true)
321
+ .attr("x", left).attr("y", height - 5)
322
+ .style("font-family", canvas.font_family)
323
+ .style("font-size", "10px")
324
+ .text(axis.xlimit_label);
325
+ }
326
+ if (settings.misc.source) {
327
+ root.append("text")
328
+ .classed("misc-source", true)
329
+ .attr("x", right).attr("y", height - 5)
330
+ .attr("text-anchor", "end")
331
+ .style("font-family", canvas.font_family)
332
+ .style("font-size", "10px")
333
+ .style("font-style", "italic")
334
+ .text(settings.misc.source);
335
+ }
336
+ }
337
+
338
+ drawControlLines(root, scale, top, bottom, lines) {
339
+ const draw = (value, cssClass, dash) => root.append("line")
340
+ .classed(cssClass, true)
341
+ .attr("x1", scale(value)).attr("x2", scale(value))
342
+ .attr("y1", top).attr("y2", bottom)
343
+ .attr("stroke", lines.colour)
344
+ .attr("stroke-width", lines.width)
345
+ .attr("stroke-dasharray", dash);
346
+ if (lines.show_99) {
347
+ draw(lines.lower_99, "misc-limit-99", lines.type_99);
348
+ draw(lines.upper_99, "misc-limit-99", lines.type_99);
349
+ }
350
+ if (lines.show_95) {
351
+ draw(lines.lower_95, "misc-limit-95", lines.type_95);
352
+ draw(lines.upper_95, "misc-limit-95", lines.type_95);
353
+ }
354
+ if (lines.show_zero) draw(0, "misc-zero-line", null);
355
+ }
356
+
357
+ drawHeader(root, scale, top, settings) {
358
+ const miscSettings = settings.misc;
359
+ const lines = settings.lines;
360
+ root.append("text")
361
+ .classed("misc-direction-unfavourable", true)
362
+ .attr("x", scale(-4)).attr("y", top - 10)
363
+ .attr("text-anchor", "middle")
364
+ .style("font-family", settings.canvas.font_family)
365
+ .style("font-size", `${miscSettings.direction_font_size}px`)
366
+ .style("font-weight", "bold")
367
+ .style("font-style", "italic")
368
+ .style("fill", miscSettings.unfavourable_colour)
369
+ .text(miscSettings.unfavourable_label);
370
+ root.append("text")
371
+ .classed("misc-direction-favourable", true)
372
+ .attr("x", scale(4)).attr("y", top - 10)
373
+ .attr("text-anchor", "middle")
374
+ .style("font-family", settings.canvas.font_family)
375
+ .style("font-size", `${miscSettings.direction_font_size}px`)
376
+ .style("font-weight", "bold")
377
+ .style("font-style", "italic")
378
+ .style("fill", miscSettings.favourable_colour)
379
+ .text(miscSettings.favourable_label);
380
+
381
+ const legendY = top - 35;
382
+ const entries = [
383
+ { show: lines.show_95, label: "95% Control Limits", dash: lines.type_95 },
384
+ { show: lines.show_99, label: "99.8% Control Limits", dash: lines.type_99 }
385
+ ].filter(entry => entry.show);
386
+ entries.forEach((entry, i) => {
387
+ const x = scale(-1.2 + i * 2.2);
388
+ root.append("line")
389
+ .attr("x1", x).attr("x2", x + 20)
390
+ .attr("y1", legendY).attr("y2", legendY)
391
+ .attr("stroke", lines.colour)
392
+ .attr("stroke-width", lines.width)
393
+ .attr("stroke-dasharray", entry.dash);
394
+ root.append("text")
395
+ .attr("x", x + 24).attr("y", legendY)
396
+ .attr("dominant-baseline", "middle")
397
+ .style("font-family", settings.canvas.font_family)
398
+ .style("font-size", "10px")
399
+ .text(entry.label);
400
+ });
401
+ }
402
+
403
+ updateHighlighting() {}
404
+ }
405
+
406
+ return {
407
+ Visual: Visual,
408
+ defaultSettings: defaultSettings
409
+ };
410
+ })();