@protoboardai/ext-docs 0.0.0-stage → 0.2.0
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.
- package/LICENSE +202 -0
- package/NOTICE +18 -0
- package/README.md +78 -2
- package/evals/build-datasheet/case.json +147 -0
- package/fonts/KodeMono-Bold.ttf +0 -0
- package/fonts/KodeMono-Medium.ttf +0 -0
- package/fonts/KodeMono-Regular.ttf +0 -0
- package/fonts/KodeMono-SemiBold.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/lib/blocks.ts +268 -0
- package/lib/bom.ts +126 -0
- package/lib/build.ts +202 -0
- package/lib/charts.ts +241 -0
- package/lib/context.ts +122 -0
- package/lib/derived.ts +181 -0
- package/lib/docs/assembly.ts +674 -0
- package/lib/docs/common.ts +81 -0
- package/lib/docs/datasheet.ts +875 -0
- package/lib/docs/product.ts +45 -0
- package/lib/figures.ts +195 -0
- package/lib/format.ts +221 -0
- package/lib/html.ts +135 -0
- package/lib/model.ts +312 -0
- package/lib/package/assembly.ts +268 -0
- package/lib/package/common.ts +109 -0
- package/lib/package/plan.ts +432 -0
- package/lib/package/spec.ts +203 -0
- package/lib/png.ts +91 -0
- package/lib/render/cdp.ts +5 -0
- package/lib/render/page/render.html +17 -0
- package/lib/render/page/render.js +390 -0
- package/lib/render/renderer.ts +161 -0
- package/lib/render/server.ts +105 -0
- package/lib/standin.ts +36 -0
- package/lib/standins/multirotor.ts +283 -0
- package/lib/templates/doc.css +247 -0
- package/lib/templates/flow.js +131 -0
- package/lib/testdata.ts +85 -0
- package/lib/types.ts +120 -0
- package/lib/values.ts +309 -0
- package/lib/verify.ts +276 -0
- package/ops/docs.ts +52 -0
- package/ops/package.ts +9 -0
- package/package.json +51 -4
- package/protoboard-extension.json +83 -0
- package/skills/uhd-tech-docs/SKILL.md +234 -0
- package/skills/uhd-tech-docs/references/docspec.md +56 -0
- package/skills/uhd-tech-docs/references/house-style.md +50 -0
- package/skills/uhd-tech-docs/references/style-study.md +270 -0
- package/skills/uhd-tech-docs/references/test-data.md +49 -0
- package/skills/uhd-tech-docs/references/value-queries.md +67 -0
- package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
- package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
- package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
- package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
package/lib/charts.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print charts as inline SVG: hairline grid, direct labels, one accent
|
|
3
|
+
* series, greys for context. Data drawn from test-data files carries the
|
|
4
|
+
* file's status; stand-in data is watermarked "STAND-IN DATA — not measured".
|
|
5
|
+
*/
|
|
6
|
+
import { formatNumber } from "./format.ts";
|
|
7
|
+
import { esc } from "./html.ts";
|
|
8
|
+
|
|
9
|
+
export const INK = "#1b1b1b";
|
|
10
|
+
export const MUTED = "#6f6f6f";
|
|
11
|
+
export const GRID = "#e4e4e4";
|
|
12
|
+
export const ACCENT = "#ff5c00";
|
|
13
|
+
export const SERIES = [ACCENT, "#1b1b1b", "#8c8c8c", "#c46a2c", "#4a6fa5"];
|
|
14
|
+
|
|
15
|
+
export interface Axis {
|
|
16
|
+
label: string;
|
|
17
|
+
unit?: string;
|
|
18
|
+
min?: number;
|
|
19
|
+
max?: number;
|
|
20
|
+
ticks?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Series {
|
|
24
|
+
label: string;
|
|
25
|
+
points: [number, number][];
|
|
26
|
+
color?: string;
|
|
27
|
+
dashed?: boolean;
|
|
28
|
+
markers?: boolean;
|
|
29
|
+
/** Draw only markers (measured/source points). */
|
|
30
|
+
markersOnly?: boolean;
|
|
31
|
+
axis?: "y" | "y2";
|
|
32
|
+
/** Label placed at the last point (direct labelling). */
|
|
33
|
+
endLabel?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChartBase {
|
|
37
|
+
width?: number;
|
|
38
|
+
height?: number;
|
|
39
|
+
/** "standin" | "measured" | "source" | "derived" — standin draws the watermark. */
|
|
40
|
+
status?: string;
|
|
41
|
+
/** Test id(s) the chart draws, for the verifier. */
|
|
42
|
+
testIds?: string[];
|
|
43
|
+
title?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function niceTicks(min: number, max: number, count = 5): number[] {
|
|
47
|
+
const span = max - min || 1;
|
|
48
|
+
const step0 = span / count;
|
|
49
|
+
const mag = 10 ** Math.floor(Math.log10(step0));
|
|
50
|
+
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= count + 0.5) ?? mag * 10;
|
|
51
|
+
const out: number[] = [];
|
|
52
|
+
for (let v = Math.ceil(min / step - 1e-9) * step; v <= max + 1e-9; v += step) out.push(Number(v.toFixed(10)));
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function niceMax(v: number): number {
|
|
57
|
+
if (v <= 0) return 1;
|
|
58
|
+
const mag = 10 ** Math.floor(Math.log10(v));
|
|
59
|
+
for (const m of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (m * mag >= v) return m * mag;
|
|
60
|
+
return 10 * mag;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const axisTitle = (a: Axis) => esc(a.unit ? `${a.label} (${a.unit})` : a.label);
|
|
64
|
+
|
|
65
|
+
function watermark(w: number, h: number, status?: string): string {
|
|
66
|
+
if (status !== "standin") return "";
|
|
67
|
+
const cx = w / 2;
|
|
68
|
+
const cy = h / 2;
|
|
69
|
+
return `<g class="standin-mark" pointer-events="none">
|
|
70
|
+
<text x="${cx}" y="${cy}" transform="rotate(-18 ${cx} ${cy})" text-anchor="middle" dominant-baseline="middle"
|
|
71
|
+
font-size="${Math.min(22, w / 22)}" font-weight="700" fill="${ACCENT}" fill-opacity="0.13" letter-spacing="1.5">STAND-IN DATA — NOT MEASURED</text>
|
|
72
|
+
<rect x="${w / 2 - 58}" y="1" width="116" height="14" rx="2" fill="#fff" stroke="${ACCENT}" stroke-width="0.8"/>
|
|
73
|
+
<text x="${w / 2}" y="10.8" text-anchor="middle" font-size="7.2" font-weight="700" fill="${ACCENT}" letter-spacing="0.6">STAND-IN · NOT MEASURED</text>
|
|
74
|
+
</g>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function frame(w: number, h: number, body: string, base: ChartBase): string {
|
|
78
|
+
const data = base.testIds?.length ? ` data-test="${esc(base.testIds.join(" "))}"` : "";
|
|
79
|
+
return `<svg class="chart" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" data-status="${esc(base.status ?? "source")}"${data} xmlns="http://www.w3.org/2000/svg" font-family="inherit">${body}${watermark(w, h, base.status)}</svg>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function lineChart(o: ChartBase & { x: Axis; y: Axis; y2?: Axis; series: Series[]; legend?: boolean; annotations?: { x: number; y: number; label: string; axis?: "y" | "y2" }[] }): string {
|
|
83
|
+
const W = o.width ?? 330;
|
|
84
|
+
const H = o.height ?? 190;
|
|
85
|
+
const m = { l: 40, r: o.y2 ? 40 : 14, t: 20, b: o.legend ? 44 : 32 };
|
|
86
|
+
const pw = W - m.l - m.r;
|
|
87
|
+
const ph = H - m.t - m.b;
|
|
88
|
+
const all = (ax: "y" | "y2") => o.series.filter((s) => (s.axis ?? "y") === ax).flatMap((s) => s.points);
|
|
89
|
+
const xs = o.series.flatMap((s) => s.points.map((p) => p[0]));
|
|
90
|
+
const xmin = o.x.min ?? Math.min(...xs);
|
|
91
|
+
const xmax = o.x.max ?? Math.max(...xs);
|
|
92
|
+
const range = (a: Axis, pts: [number, number][]) => [a.min ?? Math.min(0, ...pts.map((p) => p[1])), a.max ?? niceMax(Math.max(...pts.map((p) => p[1])))];
|
|
93
|
+
const [ymin, ymax] = range(o.y, all("y"));
|
|
94
|
+
const [y2min, y2max] = o.y2 ? range(o.y2, all("y2")) : [0, 1];
|
|
95
|
+
const sx = (v: number) => m.l + ((v - xmin) / (xmax - xmin || 1)) * pw;
|
|
96
|
+
const sy = (v: number, ax: "y" | "y2" = "y") => {
|
|
97
|
+
const [a, b] = ax === "y" ? [ymin, ymax] : [y2min, y2max];
|
|
98
|
+
return m.t + ph - ((v - a) / (b - a || 1)) * ph;
|
|
99
|
+
};
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
// grid + ticks
|
|
102
|
+
for (const t of niceTicks(ymin, ymax, o.y.ticks ?? 5)) {
|
|
103
|
+
parts.push(`<line x1="${m.l}" x2="${m.l + pw}" y1="${sy(t)}" y2="${sy(t)}" stroke="${GRID}" stroke-width="0.5"/>`);
|
|
104
|
+
parts.push(`<text x="${m.l - 5}" y="${sy(t) + 2.6}" text-anchor="end" font-size="7" fill="${MUTED}">${formatNumber(t)}</text>`);
|
|
105
|
+
}
|
|
106
|
+
if (o.y2) {
|
|
107
|
+
for (const t of niceTicks(y2min, y2max, o.y2.ticks ?? 5)) {
|
|
108
|
+
parts.push(`<text x="${m.l + pw + 5}" y="${sy(t, "y2") + 2.6}" font-size="7" fill="${MUTED}">${formatNumber(t)}</text>`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const t of niceTicks(xmin, xmax, o.x.ticks ?? 6)) {
|
|
112
|
+
parts.push(`<line x1="${sx(t)}" x2="${sx(t)}" y1="${m.t + ph}" y2="${m.t + ph + 3}" stroke="${MUTED}" stroke-width="0.5"/>`);
|
|
113
|
+
parts.push(`<text x="${sx(t)}" y="${m.t + ph + 12}" text-anchor="middle" font-size="7" fill="${MUTED}">${formatNumber(t)}</text>`);
|
|
114
|
+
}
|
|
115
|
+
parts.push(`<line x1="${m.l}" x2="${m.l + pw}" y1="${m.t + ph}" y2="${m.t + ph}" stroke="${INK}" stroke-width="0.7"/>`);
|
|
116
|
+
parts.push(`<text x="${m.l + pw / 2}" y="${m.t + ph + 24}" text-anchor="middle" font-size="7.2" fill="${INK}">${axisTitle(o.x)}</text>`);
|
|
117
|
+
parts.push(`<text x="${m.l - 34}" y="${m.t - 8}" font-size="7.2" fill="${INK}">${axisTitle(o.y)}</text>`);
|
|
118
|
+
if (o.y2) parts.push(`<text x="${m.l + pw + 34}" y="${m.t - 8}" text-anchor="end" font-size="7.2" fill="${INK}">${axisTitle(o.y2)}</text>`);
|
|
119
|
+
// series
|
|
120
|
+
const labels: { x: number; y: number; anchor: string; color: string; text: string }[] = [];
|
|
121
|
+
o.series.forEach((s, i) => {
|
|
122
|
+
const color = s.color ?? SERIES[i % SERIES.length];
|
|
123
|
+
const ax = s.axis ?? "y";
|
|
124
|
+
const pts = s.points.map(([x, y]) => `${sx(x).toFixed(1)},${sy(y, ax).toFixed(1)}`);
|
|
125
|
+
if (!s.markersOnly && pts.length > 1) {
|
|
126
|
+
parts.push(`<polyline points="${pts.join(" ")}" fill="none" stroke="${color}" stroke-width="${i === 0 ? 1.6 : 1.2}" stroke-linejoin="round"${s.dashed ? ' stroke-dasharray="4 2.5"' : ""}/>`);
|
|
127
|
+
}
|
|
128
|
+
if (s.markers || s.markersOnly) {
|
|
129
|
+
for (const [x, y] of s.points) parts.push(`<circle cx="${sx(x)}" cy="${sy(y, ax)}" r="2.4" fill="#fff" stroke="${color}" stroke-width="1.2"/>`);
|
|
130
|
+
}
|
|
131
|
+
if (!o.legend && s.endLabel !== false && s.points.length && s.label) {
|
|
132
|
+
const [x, y] = s.points[s.points.length - 1];
|
|
133
|
+
const px = sx(x);
|
|
134
|
+
const anchorEnd = px > m.l + pw - 60;
|
|
135
|
+
labels.push({ x: anchorEnd ? px - 4 : px + 4, y: sy(y, ax) - 5, anchor: anchorEnd ? "end" : "start", color, text: s.label });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (o.legend) {
|
|
139
|
+
let lx = m.l;
|
|
140
|
+
o.series.forEach((s, i) => {
|
|
141
|
+
if (!s.label) return;
|
|
142
|
+
const color = s.color ?? SERIES[i % SERIES.length];
|
|
143
|
+
const y = H - 5;
|
|
144
|
+
parts.push(s.markersOnly ? `<circle cx="${lx + 5}" cy="${y - 2.5}" r="2.4" fill="#fff" stroke="${color}" stroke-width="1.2"/>` : `<line x1="${lx}" x2="${lx + 12}" y1="${y - 2.5}" y2="${y - 2.5}" stroke="${color}" stroke-width="1.6"${s.dashed ? ' stroke-dasharray="4 2.5"' : ""}/>`);
|
|
145
|
+
parts.push(`<text x="${lx + 16}" y="${y}" font-size="6.8" fill="${INK}">${esc(s.label)}</text>`);
|
|
146
|
+
lx += 26 + s.label.length * 4.3;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// direct labels, pushed apart vertically so they never overlap
|
|
150
|
+
labels.sort((a, b) => a.y - b.y);
|
|
151
|
+
for (let i = 1; i < labels.length; i++) if (labels[i].y - labels[i - 1].y < 9) labels[i].y = labels[i - 1].y + 9;
|
|
152
|
+
for (const l of labels) parts.push(`<text x="${l.x}" y="${l.y}" text-anchor="${l.anchor}" font-size="7" font-weight="600" fill="${l.color}">${esc(l.text)}</text>`);
|
|
153
|
+
for (const a of o.annotations ?? []) {
|
|
154
|
+
const x = sx(a.x);
|
|
155
|
+
const y = sy(a.y, a.axis ?? "y");
|
|
156
|
+
parts.push(`<circle cx="${x}" cy="${y}" r="2" fill="${INK}"/><text x="${x + 5}" y="${y + 10}" font-size="6.8" fill="${INK}">${esc(a.label)}</text>`);
|
|
157
|
+
}
|
|
158
|
+
return frame(W, H, parts.join(""), o);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function barChart(
|
|
162
|
+
o: ChartBase & {
|
|
163
|
+
categories: string[];
|
|
164
|
+
series: { label: string; values: number[]; color?: string }[];
|
|
165
|
+
y: Axis;
|
|
166
|
+
stacked?: boolean;
|
|
167
|
+
/** Horizontal reference lines, e.g. a rating. */
|
|
168
|
+
refs?: { value: number; label: string }[];
|
|
169
|
+
valueLabels?: boolean;
|
|
170
|
+
decimals?: number;
|
|
171
|
+
},
|
|
172
|
+
): string {
|
|
173
|
+
const W = o.width ?? 330;
|
|
174
|
+
const H = o.height ?? 190;
|
|
175
|
+
const legendH = o.series.length > 1 ? 14 : 0;
|
|
176
|
+
const m = { l: 40, r: 12, t: 20 + legendH, b: 34 };
|
|
177
|
+
const pw = W - m.l - m.r;
|
|
178
|
+
const ph = H - m.t - m.b;
|
|
179
|
+
const totals = o.categories.map((_, i) => (o.stacked ? o.series.reduce((s, x) => s + x.values[i], 0) : Math.max(...o.series.map((x) => x.values[i]))));
|
|
180
|
+
const ymax = o.y.max ?? niceMax(Math.max(...totals, ...(o.refs ?? []).map((r) => r.value)) * 1.08);
|
|
181
|
+
const sy = (v: number) => m.t + ph - (v / ymax) * ph;
|
|
182
|
+
const band = pw / o.categories.length;
|
|
183
|
+
const bw = o.stacked ? Math.min(34, band * 0.55) : Math.min(22, (band * 0.7) / o.series.length);
|
|
184
|
+
const parts: string[] = [];
|
|
185
|
+
for (const t of niceTicks(0, ymax, o.y.ticks ?? 5)) {
|
|
186
|
+
parts.push(`<line x1="${m.l}" x2="${m.l + pw}" y1="${sy(t)}" y2="${sy(t)}" stroke="${GRID}" stroke-width="0.5"/>`);
|
|
187
|
+
parts.push(`<text x="${m.l - 5}" y="${sy(t) + 2.6}" text-anchor="end" font-size="7" fill="${MUTED}">${formatNumber(t)}</text>`);
|
|
188
|
+
}
|
|
189
|
+
o.categories.forEach((c, i) => {
|
|
190
|
+
const cx = m.l + band * i + band / 2;
|
|
191
|
+
let base = 0;
|
|
192
|
+
o.series.forEach((s, k) => {
|
|
193
|
+
const v = s.values[i];
|
|
194
|
+
const color = s.color ?? SERIES[k % SERIES.length];
|
|
195
|
+
const x = o.stacked ? cx - bw / 2 : cx - (bw * o.series.length) / 2 + k * bw;
|
|
196
|
+
const y0 = o.stacked ? base : 0;
|
|
197
|
+
parts.push(`<rect x="${x.toFixed(1)}" y="${sy(y0 + v).toFixed(1)}" width="${(bw - (o.stacked ? 0 : 1.5)).toFixed(1)}" height="${Math.max(0, sy(y0) - sy(y0 + v)).toFixed(1)}" fill="${color}"/>`);
|
|
198
|
+
if (o.valueLabels && !o.stacked) parts.push(`<text x="${x + bw / 2}" y="${sy(v) - 3}" text-anchor="middle" font-size="6.6" fill="${INK}">${formatNumber(v, { d: o.decimals })}</text>`);
|
|
199
|
+
if (o.stacked) base += v;
|
|
200
|
+
});
|
|
201
|
+
if (o.valueLabels && o.stacked) parts.push(`<text x="${cx}" y="${sy(totals[i]) - 3}" text-anchor="middle" font-size="6.8" font-weight="600" fill="${INK}">${formatNumber(totals[i], { d: o.decimals })}</text>`);
|
|
202
|
+
const words = c.split(" ");
|
|
203
|
+
const lines = words.length > 1 && c.length > 12 ? [words.slice(0, Math.ceil(words.length / 2)).join(" "), words.slice(Math.ceil(words.length / 2)).join(" ")] : [c];
|
|
204
|
+
lines.forEach((ln, j) => parts.push(`<text x="${cx}" y="${m.t + ph + 11 + j * 8}" text-anchor="middle" font-size="7" fill="${INK}">${esc(ln)}</text>`));
|
|
205
|
+
});
|
|
206
|
+
for (const r of o.refs ?? []) {
|
|
207
|
+
parts.push(`<line x1="${m.l}" x2="${m.l + pw}" y1="${sy(r.value)}" y2="${sy(r.value)}" stroke="${INK}" stroke-width="0.8" stroke-dasharray="3 2"/>`);
|
|
208
|
+
parts.push(`<text x="${m.l + pw}" y="${sy(r.value) - 3}" text-anchor="end" font-size="6.8" fill="${INK}">${esc(r.label)}</text>`);
|
|
209
|
+
}
|
|
210
|
+
parts.push(`<line x1="${m.l}" x2="${m.l + pw}" y1="${m.t + ph}" y2="${m.t + ph}" stroke="${INK}" stroke-width="0.7"/>`);
|
|
211
|
+
parts.push(`<text x="${m.l - 34}" y="${m.t - 8}" font-size="7.2" fill="${INK}">${axisTitle(o.y)}</text>`);
|
|
212
|
+
if (o.series.length > 1) {
|
|
213
|
+
let lx = m.l;
|
|
214
|
+
o.series.forEach((s, k) => {
|
|
215
|
+
const color = s.color ?? SERIES[k % SERIES.length];
|
|
216
|
+
parts.push(`<rect x="${lx}" y="6" width="8" height="8" fill="${color}"/><text x="${lx + 11}" y="13" font-size="7" fill="${INK}">${esc(s.label)}</text>`);
|
|
217
|
+
lx += 18 + s.label.length * 4.6;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return frame(W, H, parts.join(""), o);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Horizontal utilisation bars (rails, budgets): value of capacity with a percentage. */
|
|
224
|
+
export function meterChart(o: ChartBase & { rows: { label: string; value: number; capacity: number; unit: string; note?: string }[] }): string {
|
|
225
|
+
const W = o.width ?? 330;
|
|
226
|
+
const rowH = 36;
|
|
227
|
+
const H = o.rows.length * rowH + 4;
|
|
228
|
+
const bw = W - 44;
|
|
229
|
+
const parts: string[] = [];
|
|
230
|
+
o.rows.forEach((r, i) => {
|
|
231
|
+
const y = 4 + i * rowH;
|
|
232
|
+
const frac = r.capacity ? Math.min(1, r.value / r.capacity) : 0;
|
|
233
|
+
parts.push(`<text x="0" y="${y + 8}" font-size="7.6" font-weight="600" fill="${INK}">${esc(r.label)}</text>`);
|
|
234
|
+
parts.push(`<text x="${bw}" y="${y + 8}" font-size="6.8" text-anchor="end" fill="${MUTED}">${formatNumber(r.value)} of ${formatNumber(r.capacity)} ${esc(r.unit)}</text>`);
|
|
235
|
+
parts.push(`<rect x="0" y="${y + 12}" width="${bw}" height="8" fill="#efefef"/>`);
|
|
236
|
+
parts.push(`<rect x="0" y="${y + 12}" width="${(bw * frac).toFixed(1)}" height="8" fill="${frac > 0.8 ? "#c62828" : ACCENT}"/>`);
|
|
237
|
+
parts.push(`<text x="${bw + 5}" y="${y + 18.6}" font-size="7.4" font-weight="600" fill="${INK}">${r.capacity ? Math.round(frac * 100) + " %" : "n/a"}</text>`);
|
|
238
|
+
if (r.note) parts.push(`<text x="0" y="${y + 29}" font-size="6.4" fill="${MUTED}">${esc(r.note)}</text>`);
|
|
239
|
+
});
|
|
240
|
+
return frame(W, H, parts.join(""), o);
|
|
241
|
+
}
|
package/lib/context.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the document context for a project's system: model, assembly,
|
|
3
|
+
* checks, wiring (including sub-assemblies), scenarios, BOM, test data and
|
|
4
|
+
* the resolver.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import type { ModuleDef } from "@deltarobotics/uhd";
|
|
9
|
+
import { checkSystem } from "@deltarobotics/uhd";
|
|
10
|
+
import { isNet, wiringChecklist, type WiringStep } from "@deltarobotics/uhd";
|
|
11
|
+
import { boardModules, type Project } from "@protoboardai/core";
|
|
12
|
+
import { buildBom, type BomRow } from "./bom.ts";
|
|
13
|
+
import { buildScene, loadSystem, type LoadedSystem } from "./model.ts";
|
|
14
|
+
import { loadTestData } from "./testdata.ts";
|
|
15
|
+
import { DERIVED } from "./derived.ts";
|
|
16
|
+
import { instanceMap, makeResolver, type DocContext } from "./values.ts";
|
|
17
|
+
|
|
18
|
+
export interface BuiltContext extends DocContext {
|
|
19
|
+
testErrors: string[];
|
|
20
|
+
/** The documents' BOM file as committed (`docs/<system-id>-bom.json`): what the documents publish. */
|
|
21
|
+
generatedBom?: BomRow[];
|
|
22
|
+
/** buildBom() from the live model, to detect a stale BOM file. */
|
|
23
|
+
liveBom: BomRow[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Project-relative path of the BOM file the documents publish. */
|
|
27
|
+
export const bomFile = (sys: LoadedSystem) => `${sys.docsDir}/${sys.system.id}-bom.json`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wiring for the system and every sub-assembly that declares links (ids
|
|
31
|
+
* prefixed with the instance path). A circuit board (a module with Net
|
|
32
|
+
* interfaces) is not descended into: its links are its copper, made by the
|
|
33
|
+
* fab, not wired at the bench.
|
|
34
|
+
*/
|
|
35
|
+
export function fullWiring(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined): WiringStep[] {
|
|
36
|
+
const out: WiringStep[] = [...wiringChecklist(system, lookup)];
|
|
37
|
+
const visit = (d: ModuleDef, prefix: string[]) => {
|
|
38
|
+
for (const c of d.children ?? []) {
|
|
39
|
+
const def = lookup(c.moduleDefId);
|
|
40
|
+
if (!def || def.kind === "harness") continue;
|
|
41
|
+
const path = [...prefix, c.id];
|
|
42
|
+
// a circuit board (a module with Net interfaces, or drawn whole by a body of its own): its links are
|
|
43
|
+
// copper, made by the fab, not wired at the bench
|
|
44
|
+
if (def.interfaces.some(isNet) || (def.artifacts?.some((a) => a.role === "body" && a.format === "glb") && def.children?.length)) continue;
|
|
45
|
+
if (def.links?.length) {
|
|
46
|
+
for (const s of wiringChecklist(def, lookup)) {
|
|
47
|
+
if (s.domain === "mechanical") continue;
|
|
48
|
+
const p = path.join("/");
|
|
49
|
+
out.push({
|
|
50
|
+
...s,
|
|
51
|
+
linkId: `${p}/${s.linkId}`,
|
|
52
|
+
a: `${p}/${s.a}`,
|
|
53
|
+
b: `${p}/${s.b}`,
|
|
54
|
+
harness: s.harness ? `${p}/${s.harness}` : undefined,
|
|
55
|
+
connections: s.connections.map((c2) => ({ ...c2, from: `${p}/${c2.from}`, to: `${p}/${c2.to}` })),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
visit(def, path);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
visit(system, []);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* checkSystem on each circuit board below a system (a definition with Net
|
|
68
|
+
* interfaces), once per definition at its first instance: the root's check
|
|
69
|
+
* does not descend into a board's nets, so a fault on a board's copper is only
|
|
70
|
+
* seen by checking the board itself. Filtered as `protoboard check` does: the
|
|
71
|
+
* root's check covers the tree for unknown categories and passive overrides,
|
|
72
|
+
* and a board is powered from outside it (through its exports), so "unpowered"
|
|
73
|
+
* is not a finding on the board alone.
|
|
74
|
+
*/
|
|
75
|
+
export function boardChecks(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined) {
|
|
76
|
+
return boardModules(system, lookup as Parameters<typeof boardModules>[1]).map((b) => ({
|
|
77
|
+
path: b.path,
|
|
78
|
+
id: b.def.id,
|
|
79
|
+
diagnostics: checkSystem(b.def, lookup).diagnostics.filter((x) => !["unknown_category", "passive_override", "unpowered"].includes(x.rule)),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** checkSystem on a system plus each circuit board below it (`boardChecks`), board findings prefixed with the board's path. */
|
|
84
|
+
export function checkWithBoards(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined) {
|
|
85
|
+
const root = checkSystem(system, lookup);
|
|
86
|
+
const extra = boardChecks(system, lookup).flatMap(({ path: at, diagnostics }) =>
|
|
87
|
+
diagnostics.map((x) => ({ ...x, id: `${at}/${x.id}`, message: `${at}: ${x.message}`, refs: x.refs.map((r) => (r.startsWith("link:") ? `link:${at}/${r.slice(5)}` : `${at}/${r}`)) })),
|
|
88
|
+
);
|
|
89
|
+
return { ...root, diagnostics: [...root.diagnostics, ...extra] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function buildContext(p: Project): Promise<BuiltContext> {
|
|
93
|
+
const sys = await loadSystem(p);
|
|
94
|
+
const scene = buildScene(sys);
|
|
95
|
+
// the system's own check; its boards' findings are in `boards` (the datasheet's rail table reads both)
|
|
96
|
+
const checks = checkSystem(sys.system, sys.lookup);
|
|
97
|
+
const wiring = fullWiring(sys.system, sys.lookup);
|
|
98
|
+
// each scenario's system and its boards (a board's own nets are checked on the board module, as protoboard check does)
|
|
99
|
+
const scenarioChecks = sys.scenarios.map((s) => ({ id: s.id, label: s.label, description: s.description, diagnostics: checkWithBoards(s.system, s.lookup).diagnostics }));
|
|
100
|
+
const boards = boardChecks(sys.system, sys.lookup);
|
|
101
|
+
const { tests, errors } = loadTestData(sys.absDocs, sys.docsDir);
|
|
102
|
+
const genPath = join(sys.root, bomFile(sys));
|
|
103
|
+
const generatedBom = existsSync(genPath) ? (JSON.parse(readFileSync(genPath, "utf8")) as BomRow[]) : undefined;
|
|
104
|
+
const liveBom = buildBom(sys.system, sys.lookup, sys.evidenceDirs);
|
|
105
|
+
const partial = {
|
|
106
|
+
sys,
|
|
107
|
+
scene,
|
|
108
|
+
bom: generatedBom ?? liveBom,
|
|
109
|
+
checks,
|
|
110
|
+
wiring,
|
|
111
|
+
scenarioChecks,
|
|
112
|
+
boards,
|
|
113
|
+
tests,
|
|
114
|
+
derived: { ...DERIVED },
|
|
115
|
+
instances: instanceMap(sys.system, sys.lookup),
|
|
116
|
+
testErrors: errors,
|
|
117
|
+
generatedBom,
|
|
118
|
+
liveBom,
|
|
119
|
+
};
|
|
120
|
+
makeResolver(partial as any);
|
|
121
|
+
return partial as unknown as BuiltContext;
|
|
122
|
+
}
|
package/lib/derived.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named derivations (`derived:<name>` queries). Each lists the model queries
|
|
3
|
+
* it reads, so a document can show the formula and inputs, and the verifier
|
|
4
|
+
* can recompute it. Derivations find modules by what they are (category),
|
|
5
|
+
* so they work for any system that has the parts, not just the quadcopter.
|
|
6
|
+
*/
|
|
7
|
+
import type { ModuleDef } from "@deltarobotics/uhd";
|
|
8
|
+
import { fullThrottle, thrustTests } from "@deltarobotics/uhd";
|
|
9
|
+
import { massByDefinition, moduleMass, systemMass, type ModuleMass, type SystemMass } from "@deltarobotics/uhd";
|
|
10
|
+
import { cadVolume, category } from "./model.ts";
|
|
11
|
+
import type { DerivedDef, DocContext } from "./values.ts";
|
|
12
|
+
|
|
13
|
+
/** System mass from the model (UHD systemMass): stated weights, else CAD volume × material density. */
|
|
14
|
+
export function modelMass(ctx: DocContext): SystemMass {
|
|
15
|
+
return systemMass(ctx.sys.system, ctx.sys.lookup, cadVolume(ctx.sys));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Model queries a module's mass rests on. */
|
|
19
|
+
function massInputs(def: ModuleDef, m?: ModuleMass): string[] {
|
|
20
|
+
if (!m) return [];
|
|
21
|
+
if (m.basis === "stated") return [`def:${def.id}:domains[domain=mechanical].weight_g`];
|
|
22
|
+
return [`def:${def.id}:domains[domain=mechanical].material.density_g_cm3`];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const motors = (ctx: DocContext) => ctx.scene.assembly.instances.filter((i) => i.kind === "module" && category(i.def) === "motor");
|
|
26
|
+
const firstOf = (ctx: DocContext, cat: string) => ctx.scene.assembly.instances.find((i) => i.kind === "module" && category(i.def) === cat)?.def;
|
|
27
|
+
|
|
28
|
+
export const DERIVED: Record<string, DerivedDef> = {
|
|
29
|
+
"mass.all_up": {
|
|
30
|
+
label: "All-up weight of parts with a mass in the model",
|
|
31
|
+
unit: "g",
|
|
32
|
+
formula: "systemMass: ∑ quantity × (weight_g, else CAD volume × material density) over every instance; instances with neither are listed, not guessed",
|
|
33
|
+
compute: (ctx) => {
|
|
34
|
+
const m = modelMass(ctx);
|
|
35
|
+
const groups = massByDefinition(m).filter((g) => g.mass);
|
|
36
|
+
return {
|
|
37
|
+
value: Number(m.totalG.toFixed(1)),
|
|
38
|
+
inputs: groups.flatMap((g) => massInputs(g.def, g.mass)),
|
|
39
|
+
note: m.missing.length ? `excludes ${m.missing.length} part(s) with no mass: ${m.missing.map((e) => e.def.name).join(", ")}; ${m.assumedG.toFixed(1)} g of it is computed from assumed material/geometry` : undefined,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
"mass.assumed": {
|
|
44
|
+
label: "Part of the all-up weight computed from assumed material or nominal geometry",
|
|
45
|
+
unit: "g",
|
|
46
|
+
formula: "∑ systemMass entries whose material carries an assumption",
|
|
47
|
+
compute: (ctx) => {
|
|
48
|
+
const m = modelMass(ctx);
|
|
49
|
+
const groups = massByDefinition(m).filter((g) => g.mass?.assumed);
|
|
50
|
+
return { value: Number(m.assumedG.toFixed(1)), inputs: groups.flatMap((g) => massInputs(g.def, g.mass)) };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
"mass.missing_count": {
|
|
54
|
+
label: "Parts without a mass in the model",
|
|
55
|
+
formula: "count (by quantity) of systemMass entries with no weight_g and no material + CAD volume",
|
|
56
|
+
compute: (ctx) => {
|
|
57
|
+
const m = modelMass(ctx);
|
|
58
|
+
return { value: m.missing.reduce((s, e) => s + e.quantity, 0), inputs: [], note: m.missing.map((e) => `${e.def.name} (${e.path})`).join(", ") };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
"mass.module": {
|
|
62
|
+
label: "Mass of one module",
|
|
63
|
+
unit: "g",
|
|
64
|
+
formula: "weight_g if stated, else CAD body volume (generator manifest) × domains[mechanical].material.density_g_cm3",
|
|
65
|
+
compute: (ctx, args = []) => {
|
|
66
|
+
const def = ctx.sys.lookup(args[0] ?? "");
|
|
67
|
+
const m = def ? moduleMass(def, cadVolume(ctx.sys)) : undefined;
|
|
68
|
+
return {
|
|
69
|
+
value: m ? Number(m.massG.toFixed(m.massG < 1 ? 2 : 1)) : undefined,
|
|
70
|
+
inputs: def ? massInputs(def, m) : [],
|
|
71
|
+
note: m?.basis === "cad_volume" ? `${(m.volumeMm3! / 1000).toFixed(2)} cm³ × ${m.material!.density_g_cm3} g/cm³ (${m.material!.name})${m.assumed ? "; assumption: " + m.material!.assumption : ""}` : undefined,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
"propulsion.motor_count": {
|
|
76
|
+
label: "Motors",
|
|
77
|
+
formula: "count of motor instances",
|
|
78
|
+
compute: (ctx) => ({ value: motors(ctx).length, inputs: [] }),
|
|
79
|
+
},
|
|
80
|
+
"propulsion.thrust_full_total": {
|
|
81
|
+
label: "Static thrust, all motors at full throttle",
|
|
82
|
+
unit: "g",
|
|
83
|
+
formula: "motors × thrust_g of the top (100 %) row of the first thrust table",
|
|
84
|
+
compute: (ctx) => {
|
|
85
|
+
const m = motors(ctx);
|
|
86
|
+
const d = m[0]?.def;
|
|
87
|
+
const t = d && thrustTests(d)[0];
|
|
88
|
+
const top = t ? fullThrottle(t) : undefined;
|
|
89
|
+
return {
|
|
90
|
+
value: top ? m.length * top.thrust_g : undefined,
|
|
91
|
+
inputs: d && top ? [`def:${d.id}:traits[type=performance].params.thrust_tests[0].rows[throttle_pct=${top.throttle_pct}].thrust_g`] : [],
|
|
92
|
+
note: t ? `manufacturer table with ${t.propeller} at ${t.supply_V} V (not the fitted propeller)` : "no thrust test in the motor model",
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"propulsion.current_full_total": {
|
|
97
|
+
label: "Static current, all motors at full throttle",
|
|
98
|
+
unit: "A",
|
|
99
|
+
formula: "motors × current_A of the top (100 %) row of the first thrust table",
|
|
100
|
+
compute: (ctx) => {
|
|
101
|
+
const m = motors(ctx);
|
|
102
|
+
const d = m[0]?.def;
|
|
103
|
+
const t = d && thrustTests(d)[0];
|
|
104
|
+
const top = t ? fullThrottle(t) : undefined;
|
|
105
|
+
return {
|
|
106
|
+
value: top ? Number((m.length * top.current_A).toFixed(2)) : undefined,
|
|
107
|
+
inputs: d && top ? [`def:${d.id}:traits[type=performance].params.thrust_tests[0].rows[throttle_pct=${top.throttle_pct}].current_A`] : [],
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
"power.battery_c_ratio_peak": {
|
|
112
|
+
label: "Peak propulsion current / battery continuous rating",
|
|
113
|
+
formula: "checks propulsion_current totalPeak ÷ battery max_current",
|
|
114
|
+
compute: (ctx) => {
|
|
115
|
+
const d = ctx.checks.diagnostics.find((x) => x.rule === "propulsion_current");
|
|
116
|
+
const det = (d as any)?.details;
|
|
117
|
+
return {
|
|
118
|
+
value: det ? Number((det.totalPeak / det.cont).toFixed(3)) : undefined,
|
|
119
|
+
inputs: ["sys:checks.diagnostics[rule=propulsion_current].details.totalPeak", "sys:checks.diagnostics[rule=propulsion_current].details.cont"],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
"power.battery_energy_Wh": {
|
|
124
|
+
label: "Battery energy",
|
|
125
|
+
unit: "Wh",
|
|
126
|
+
formula: "battery voltage × capacity",
|
|
127
|
+
compute: (ctx) => {
|
|
128
|
+
const b = firstOf(ctx, "battery");
|
|
129
|
+
const out = b?.interfaces.find((i) => i.parameters?.some((p) => p.id === "capacity"));
|
|
130
|
+
const v = out?.parameters?.find((p) => p.id === "voltage")?.value;
|
|
131
|
+
const c = out?.parameters?.find((p) => p.id === "capacity")?.value;
|
|
132
|
+
return {
|
|
133
|
+
value: v !== undefined && c !== undefined ? Number(((v * c) / 1000).toFixed(2)) : undefined,
|
|
134
|
+
inputs: b && out ? [`def:${b.id}:interfaces[id=${out.id}].parameters[id=voltage].value`, `def:${b.id}:interfaces[id=${out.id}].parameters[id=capacity].value`] : [],
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
frameDistance: {
|
|
139
|
+
label: "Distance between two interface frames in the assembly",
|
|
140
|
+
unit: "mm",
|
|
141
|
+
formula: "|placement(a) · a.frame.origin − placement(b) · b.frame.origin| from assemble()",
|
|
142
|
+
compute: (ctx, args = []) => {
|
|
143
|
+
const pts = args.map((spec) => {
|
|
144
|
+
const dot = spec.lastIndexOf(".");
|
|
145
|
+
const path = spec.slice(0, dot);
|
|
146
|
+
const id = spec.slice(dot + 1);
|
|
147
|
+
const body = ctx.scene.bodies.find((b) => b.path.join("/") === path && b.def.interfaces.some((i) => i.id === id && i.geometry?.frame));
|
|
148
|
+
const f = body?.def.interfaces.find((i) => i.id === id)?.geometry?.frame;
|
|
149
|
+
if (!body || !f) return undefined;
|
|
150
|
+
const m = body.matrix;
|
|
151
|
+
const o = f.origin;
|
|
152
|
+
return [m[0] * o[0] + m[1] * o[1] + m[2] * o[2] + m[3], m[4] * o[0] + m[5] * o[1] + m[6] * o[2] + m[7], m[8] * o[0] + m[9] * o[1] + m[10] * o[2] + m[11]];
|
|
153
|
+
});
|
|
154
|
+
const [a, b] = pts;
|
|
155
|
+
const inst = (spec: string) => {
|
|
156
|
+
const dot = spec.lastIndexOf(".");
|
|
157
|
+
const path = spec.slice(0, dot);
|
|
158
|
+
const id = spec.slice(dot + 1);
|
|
159
|
+
return `inst:${path}:interfaces[id=${id}].geometry.frame.origin`;
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
value: a && b ? Number(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]).toFixed(2)) : undefined,
|
|
163
|
+
inputs: args.map(inst),
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
diagCount: {
|
|
168
|
+
label: "Design-check diagnostics of one severity in a scenario",
|
|
169
|
+
formula: "count of checkSystem(scenario).diagnostics, plus each board below it, with the severity",
|
|
170
|
+
compute: (ctx, args = []) => {
|
|
171
|
+
const [scenario, severity] = args;
|
|
172
|
+
const s = ctx.scenarioChecks.find((x) => x.id === scenario);
|
|
173
|
+
return { value: s ? s.diagnostics.filter((d) => d.severity === severity).length : undefined, inputs: [`sys:scenarios[id=${scenario}].diagnostics`] };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
"mech.hardware_count": {
|
|
177
|
+
label: "Fasteners and spacers placed by the assembly",
|
|
178
|
+
formula: "assemble().hardware.length",
|
|
179
|
+
compute: (ctx) => ({ value: ctx.scene.assembly.hardware.length, inputs: ["sys:assembly.hardware"] }),
|
|
180
|
+
},
|
|
181
|
+
};
|