1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using UAnalytics.Interop;
namespace UAnalytics;
public sealed class AnalyticsClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private bool _disposed;
public string GetVersion()
{
var ptr = NativeInterop.uanalytics_version();
var version = Marshal.PtrToStringUTF8(ptr) ?? "unknown";
NativeInterop.uanalytics_free_string(ptr);
return version;
}
// ── SPC ──
/// <summary>
/// X-bar/R chart. Returns the same JSON the WASM binding does:
/// <c>xbar_cl</c>/<c>xbar_ucl</c>/<c>xbar_lcl</c>, <c>r_cl</c>/<c>r_ucl</c>/<c>r_lcl</c>,
/// <c>sigma_hat</c>, per-point <c>xbar_points</c>/<c>r_points</c> (each with
/// <c>index</c>, <c>value</c>, <c>violations</c>) and <c>in_control</c>.
/// <paramref name="rules"/> names the run tests to apply, in the vocabulary
/// <c>violations</c> reports; <c>null</c> applies all eight Nelson tests, an
/// empty array applies the control limits only.
/// </summary>
public JsonElement XbarRChart(double[][] subgroups, string[]? rules = null)
=> CallNative(NativeInterop.uanalytics_xbar_r_chart,
rules is null ? new { subgroups } : (object)new { subgroups, rules });
/// <summary>
/// P chart. Each sample is <c>[defectives, sampleSize]</c> -- the order the
/// chart's own <c>add_sample</c> takes, and the order the WASM binding uses.
/// Returns <c>p_bar</c>, per-point <c>points</c> (<c>index</c>, <c>value</c>,
/// <c>ucl</c>, <c>cl</c>, <c>lcl</c>, <c>out_of_control</c>) and <c>in_control</c>.
/// A sample with more defectives than its size, or a size of zero, is rejected
/// by its row rather than dropped.
/// </summary>
public JsonElement PChart(ulong[][] samples)
=> CallNative(NativeInterop.uanalytics_p_chart, new { samples });
/// <summary>
/// Laney P' chart for over- or under-dispersed proportions. Same
/// <c>[defectives, sampleSize]</c> samples as <see cref="PChart"/>; at least three.
/// Returns <c>p_bar</c>, <c>phi</c> and per-point <c>points</c>.
/// </summary>
public JsonElement LaneyPChart(ulong[][] samples)
=> CallNative(NativeInterop.uanalytics_laney_p_chart, new { samples });
/// <summary>
/// X-bar/S chart. Same request and response shape as <see cref="XbarRChart"/> with
/// <c>s_*</c> limits in place of <c>r_*</c>; the usual choice once subgroups exceed about
/// ten values. Returns <c>sigma_hat</c> (<c>S-bar / c4</c>).
/// </summary>
public JsonElement XbarSChart(double[][] subgroups, string[]? rules = null)
=> CallNative(NativeInterop.uanalytics_xbar_s_chart,
rules is null ? new { subgroups } : (object)new { subgroups, rules });
/// <summary>
/// Individual / Moving-Range chart for a series of single observations. Returns
/// <c>sigma_hat</c> (<c>MR-bar / d2(2)</c>) -- pass it as <c>sigmaWithin</c> to
/// <see cref="ProcessCapability"/> for individual data, which no longer estimates one itself.
/// </summary>
public JsonElement ImrChart(double[] values, string[]? rules = null)
=> CallNative(NativeInterop.uanalytics_imr_chart,
rules is null ? new { values } : (object)new { values, rules });
/// <summary>
/// Applies the run tests to a series against one set of limits -- the engine the charts
/// use, callable on its own. One point per value, in order, each with its
/// <c>violations</c>.
/// </summary>
public JsonElement RunRules(double[] values, double ucl, double cl, double lcl,
string[]? rules = null)
=> CallNative(NativeInterop.uanalytics_run_rules,
rules is null
? new { values, limits = new { ucl, cl, lcl } }
: (object)new { values, limits = new { ucl, cl, lcl }, rules });
// ── Capability ──
/// <summary>
/// Capability indices. <paramref name="sigmaWithin"/> is the short-term sigma from a
/// control chart (the <c>sigma_hat</c> that <see cref="XbarRChart"/> returns). Without it
/// the short-term indices (<c>cp</c>, <c>cpk</c>, <c>cpu</c>, <c>cpl</c>) are <c>null</c> and
/// <c>sigma_source</c> is <c>"overall"</c> -- a flat vector carries no subgroup structure to
/// estimate one from, and this client no longer guesses one from the moving range.
/// Same JSON as the WASM binding.
/// </summary>
public JsonElement ProcessCapability(double[] data, double? usl, double? lsl, double? target = null,
double? sigmaWithin = null)
=> CallNative(NativeInterop.uanalytics_process_capability,
new { data, usl, lsl, target, sigma_within = sigmaWithin });
public JsonElement PercentileCapability(double[] data, double? usl, double? lsl)
=> CallNative(NativeInterop.uanalytics_percentile_capability,
new { data, usl, lsl });
// ── MSA ──
public JsonElement GageRRXbarR(double[][][] measurements, double? tolerance = null)
=> CallNative(NativeInterop.uanalytics_gage_rr_xbar_r,
new { measurements, tolerance });
public JsonElement GageRRAnova(double[][][] measurements, double? tolerance = null)
=> CallNative(NativeInterop.uanalytics_gage_rr_anova,
new { measurements, tolerance });
// ── Weibull ──
public JsonElement WeibullMle(double[] failureTimes)
=> CallNative(NativeInterop.uanalytics_weibull_mle,
new { failure_times = failureTimes });
// ── Detection ──
/// <summary>
/// PELT changepoint detection. <paramref name="penalty"/> is a positive number, or
/// <c>null</c> for BIC. <paramref name="cost"/> is <c>"l2"</c> (mean change, the default)
/// or <c>"normal"</c> (mean and variance). Returns <c>changepoints</c> and
/// <c>n_segments</c>. Same JSON as the WASM binding; the penalty was formerly a string.
/// </summary>
public JsonElement DetectChangepoints(double[] data, double? penalty = null, int? minSegmentLen = null,
string cost = "l2")
=> CallNative(NativeInterop.uanalytics_detect_changepoints,
penalty is null
? new { data, cost, min_segment_len = minSegmentLen }
: (object)new { data, cost, penalty, min_segment_len = minSegmentLen });
// ── Seasonality ──
/// <summary>
/// Estimates the dominant period of a univariate series (AutoPeriod:
/// permutation-thresholded periodogram peaks refined on the ACF). The
/// response's <c>period</c> is <c>null</c>, not an error, when no
/// periodicity is found; <c>candidates</c> lists every validated period.
/// </summary>
public JsonElement EstimatePeriod(double[] data)
=> CallNative(NativeInterop.uanalytics_estimate_period, new { data });
/// <summary>
/// Scores every point for anomalies by spectral residual saliency (Ren et
/// al. 2019). Optional arguments left <c>null</c> take the paper's
/// defaults (q = 3, z = 40, τ = 3, z-score gate 1.5, 70% band, no
/// batching). The response's <c>anomalies</c> lists the flagged indices.
/// </summary>
public JsonElement SpectralResidual(double[] data, int? averagingWindow = null, int? judgementWindow = null,
double? threshold = null, double? minZscore = null, double? sensitivity = null, int? batchSize = null)
=> CallNative(NativeInterop.uanalytics_spectral_residual,
new
{
data,
averaging_window = averagingWindow,
judgement_window = judgementWindow,
threshold,
min_zscore = minZscore,
sensitivity,
batch_size = batchSize
});
// ── Correlation ──
public JsonElement CorrelationMatrix(double[][] variables)
=> CallNative(NativeInterop.uanalytics_correlation_matrix,
new { variables });
// ── Regression ──
public JsonElement SimpleRegression(double[] x, double[] y)
=> CallNative(NativeInterop.uanalytics_simple_regression,
new { x, y });
// ── Distribution ──
public JsonElement FitBest(double[] data)
=> CallNative(NativeInterop.uanalytics_fit_best,
new { data });
// ── Internal ──
private delegate int NativeFunc(string json, out IntPtr result);
private JsonElement CallNative(NativeFunc func, object request)
{
var requestJson = JsonSerializer.Serialize(request, JsonOptions);
var code = func(requestJson, out var resultPtr);
try
{
if (resultPtr == IntPtr.Zero)
throw new AnalyticsException(code, "Null result from engine");
var resultJson = Marshal.PtrToStringUTF8(resultPtr);
if (string.IsNullOrEmpty(resultJson))
throw new AnalyticsException(code, "Empty result from engine");
if (code != 0)
throw AnalyticsException.FromErrorBody(code, resultJson);
return JsonDocument.Parse(resultJson).RootElement.Clone();
}
finally
{
if (resultPtr != IntPtr.Zero)
NativeInterop.uanalytics_free_string(resultPtr);
}
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
GC.SuppressFinalize(this);
}
}
}
/// <summary>
/// A call the engine refused. <see cref="Exception.Message"/> is human-readable;
/// <see cref="Reason"/> and <see cref="Index"/> are for programs.
/// </summary>
public class AnalyticsException : Exception
{
/// <summary>Native status: -1 null pointer, -2 malformed request, -3 refused input, -4 internal panic.</summary>
public int Code { get; }
/// <summary>
/// Stable, machine-readable reason, e.g. <c>count_not_whole</c>,
/// <c>sample_size_not_positive</c>, <c>defectives_exceed_sample</c>,
/// <c>units_not_positive</c>, <c>too_few_samples</c>, <c>malformed_input</c>,
/// <c>invalid_input</c>. <c>null</c> when the engine returned no body.
/// </summary>
public string? Reason { get; }
/// <summary>Zero-based position of the offending element in its input array, when there is one.</summary>
public int? Index { get; }
public AnalyticsException(int code, string message) : base(message)
{
Code = code;
}
public AnalyticsException(int code, string message, string? reason, int? index) : base(message)
{
Code = code;
Reason = reason;
Index = index;
}
/// <summary>Reads the engine's <c>{"error", "code", "index"}</c> error body.</summary>
internal static AnalyticsException FromErrorBody(int code, string body)
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var message = root.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String
? e.GetString()!
: body;
string? reason = root.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString()
: null;
int? index = root.TryGetProperty("index", out var i) && i.ValueKind == JsonValueKind.Number
? i.GetInt32()
: null;
return new AnalyticsException(code, message, reason, index);
}
catch (JsonException)
{
return new AnalyticsException(code, body);
}
}
}