@protoboardai/ext-release 0.0.0-stage → 0.2.1
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 +13 -0
- package/README.md +290 -2
- package/evals/release-after-hand-edit/case.json +59 -0
- package/lib/bom.ts +444 -0
- package/lib/collect.ts +100 -0
- package/lib/config.ts +45 -0
- package/lib/cutlist.ts +70 -0
- package/lib/documents.ts +287 -0
- package/lib/glb.ts +85 -0
- package/lib/helpers.ts +49 -0
- package/lib/html.ts +126 -0
- package/lib/manifest.ts +159 -0
- package/lib/offers.ts +123 -0
- package/lib/preconditions.ts +141 -0
- package/lib/product.ts +64 -0
- package/lib/release.ts +279 -0
- package/lib/render.ts +203 -0
- package/lib/sections/baseline.ts +164 -0
- package/lib/sections/bom.ts +101 -0
- package/lib/sections/model.ts +78 -0
- package/lib/sections/testplan.ts +76 -0
- package/lib/sections/tests.ts +630 -0
- package/lib/sections/workorder.ts +369 -0
- package/lib/testing.ts +43 -0
- package/lib/types.ts +251 -0
- package/lib/units.ts +141 -0
- package/lib/util.ts +31 -0
- package/ops/release.ts +21 -0
- package/ops/sections.ts +23 -0
- package/package.json +52 -4
- package/protoboard-extension.json +81 -0
- package/skills/release/SKILL.md +191 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bill-of-materials section (PK2): the unit's module breakdown as
|
|
3
|
+
* bom.pdf and bom.csv, and for the root unit the whole product's ordering
|
|
4
|
+
* list (ordering-list.csv) with its quantity rule and cost coverage.
|
|
5
|
+
*/
|
|
6
|
+
import { orderingCsv, orderingList, orderTotals, SPARE_DEFAULTS, unitBom, unitBomCsv, type OrderLine } from "../bom.ts";
|
|
7
|
+
import { readFx } from "@protoboardai/ext-sourcing/lib/cost.ts";
|
|
8
|
+
import type { Block, SectionContext, SectionContribution } from "../types.ts";
|
|
9
|
+
import { num } from "../util.ts";
|
|
10
|
+
|
|
11
|
+
const ACTION: Record<string, string> = { build: "build", fabricate: "make", buy: "buy", included: "included" };
|
|
12
|
+
|
|
13
|
+
export function sectionBom({ project: p, unit: u, units, release }: SectionContext): SectionContribution[] {
|
|
14
|
+
const rows = unitBom(p, u);
|
|
15
|
+
const indent = (level: number, text: string) => `${"· ".repeat(level)}${text}`;
|
|
16
|
+
const breakdown: Block[] = [
|
|
17
|
+
{
|
|
18
|
+
kind: "paragraph",
|
|
19
|
+
text: `One build of ${u.name}, in the order of its module tree (sub-assembly → board → components). Quantities are per build; ${u.kind === "root" ? "the product is built once per order" : `the product needs ${u.perProduct}`}. The same rows are in bom.csv, keyed by instance path.`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
kind: "table",
|
|
23
|
+
columns: [
|
|
24
|
+
{ key: "item", label: "Item" },
|
|
25
|
+
{ key: "qty", label: "Qty", align: "right" },
|
|
26
|
+
{ key: "action", label: "Make / buy" },
|
|
27
|
+
{ key: "mfr", label: "Manufacturer" },
|
|
28
|
+
{ key: "mpn", label: "MPN" },
|
|
29
|
+
...(rows.some((r) => r.value) ? [{ key: "value", label: "Value" }] : []),
|
|
30
|
+
{ key: "path", label: "Instance" },
|
|
31
|
+
],
|
|
32
|
+
rows: rows.map((r) => ({ item: indent(r.level, r.item), qty: r.qty_per_unit, action: r.kind === "unit" ? "unit" : ACTION[r.action], mfr: r.manufacturer, mpn: r.mpn, value: r.value, path: r.path })),
|
|
33
|
+
caption: "Module breakdown",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
const out: SectionContribution[] = [{ blocks: breakdown, files: [{ path: "bom.csv", content: unitBomCsv(rows), role: "purchasing", description: "module breakdown of one build" }] }];
|
|
37
|
+
if (u.kind !== "root") return out;
|
|
38
|
+
|
|
39
|
+
const lines = orderingList(p, units, release.config);
|
|
40
|
+
const fx = readFx(p.root).table;
|
|
41
|
+
const t = orderTotals(lines, fx);
|
|
42
|
+
const converted = [...new Set(lines.map((l) => l.supplier_currency).filter(Boolean))].sort();
|
|
43
|
+
const blocks: Block[] = [
|
|
44
|
+
{
|
|
45
|
+
kind: "paragraph",
|
|
46
|
+
text: `Everything to obtain for ${release.build} ${release.build === 1 ? "product" : "products"}, across all units, one line per part. The full list with suppliers, offers, alternates, lifecycle and sources is ordering-list.csv.`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
kind: "figures",
|
|
50
|
+
items: [
|
|
51
|
+
{ label: "Buy", value: String(t.buy), sub: `${t.priced} with a dated price` },
|
|
52
|
+
{ label: "Make", value: String(t.fabricate), sub: "custom parts and cables" },
|
|
53
|
+
{ label: "Included", value: String(t.included), sub: "ship with another part" },
|
|
54
|
+
{ label: "Cost (priced lines)", value: t.costText || "—", sub: [t.priced < t.buy ? `${t.buy - t.priced} bought line(s) unpriced` : "every bought line priced", t.costNote].filter(Boolean).join("; ") },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
kind: "table",
|
|
59
|
+
columns: [
|
|
60
|
+
{ key: "item", label: "Item" },
|
|
61
|
+
{ key: "action", label: "Action" },
|
|
62
|
+
{ key: "needed", label: "Need", align: "right" },
|
|
63
|
+
{ key: "spares", label: "Spare", align: "right" },
|
|
64
|
+
{ key: "order", label: "Order", align: "right" },
|
|
65
|
+
{ key: "where", label: "Supplier / process" },
|
|
66
|
+
{ key: "price", label: "Unit price", align: "right" },
|
|
67
|
+
{ key: "life", label: "Lifecycle" },
|
|
68
|
+
],
|
|
69
|
+
rows: groupLines(lines),
|
|
70
|
+
caption: "Ordering list",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
kind: "note",
|
|
74
|
+
level: "tip",
|
|
75
|
+
text: `Order = need (per product × products) + spares, rounded up to whole packs and the offer's minimum order. Spares: ${SPARE_DEFAULTS.map((d) => `${d.why}: ${d.percent} %, at least ${d.min}`).join("; ")}; nothing else unless release.spares says so. Each bought line is quoted as the bill of materials quotes it: the offer chosen for it in the project (parts/bom.json), else the offer that costs least for the order among those the project holds. Every price is in US dollars${converted.length ? `; prices in ${converted.join(", ")} were converted at the rates of ${fx.date} (${fx.source}) and are marked` : ""}.`,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
out.push({ title: "Ordering list", order: 10, blocks, files: [{ path: "ordering-list.csv", content: orderingCsv(lines), role: "purchasing", description: "whole-product ordering list" }] });
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function groupLines(lines: OrderLine[]) {
|
|
83
|
+
const rows: (Record<string, string | number> | { group: string })[] = [];
|
|
84
|
+
for (const [action, title] of [["fabricate", "Make"], ["buy", "Buy"], ["included", "Included with another part"]] as const) {
|
|
85
|
+
const these = lines.filter((l) => l.action === action);
|
|
86
|
+
if (!these.length) continue;
|
|
87
|
+
rows.push({ group: title });
|
|
88
|
+
for (const l of these)
|
|
89
|
+
rows.push({
|
|
90
|
+
item: `${l.item}${l.value ? `, ${l.value}` : ""}${l.mpn ? ` (${l.mpn})` : ""}`,
|
|
91
|
+
action: l.action === "fabricate" ? "make" : l.action,
|
|
92
|
+
needed: l.needed,
|
|
93
|
+
spares: l.spares,
|
|
94
|
+
order: l.packs ? `${l.order} (${l.packs} × ${l.pack_qty})` : l.order,
|
|
95
|
+
where: l.action === "fabricate" ? l.process : l.action === "included" ? l.note : l.supplier || "no offer recorded",
|
|
96
|
+
price: l.unit_price !== null ? `${num(l.unit_price, 4)} ${l.currency}${l.supplier_currency ? ` (from ${l.supplier_currency})` : ""}` : "",
|
|
97
|
+
life: l.lifecycle,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model queries the baseline sections share: a unit's definition, mass,
|
|
3
|
+
* links (the joining operations inside one build) and boundary interfaces.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { jointTorques, systemMass, type ModuleDef, type SystemMass } from "@deltarobotics/uhd";
|
|
7
|
+
import type { Project } from "@protoboardai/core";
|
|
8
|
+
import type { Unit } from "../types.ts";
|
|
9
|
+
import { num } from "../util.ts";
|
|
10
|
+
|
|
11
|
+
export const unitDef = (p: Project, u: Unit) => p.lookup(u.definition)!;
|
|
12
|
+
|
|
13
|
+
/** Volume of a module's generated CAD body, from the geometry manifest its artifacts reference (volume_mm3). */
|
|
14
|
+
export function cadVolume(p: Project) {
|
|
15
|
+
return (def: ModuleDef): number | undefined => {
|
|
16
|
+
for (const a of def.artifacts ?? []) {
|
|
17
|
+
if (a.format !== "json" || !a.provenance?.generatedFrom) continue;
|
|
18
|
+
const r = p.artifacts.find((x) => x.definition === def.id && x.id === a.id);
|
|
19
|
+
if (r?.present !== true || !r.resolved || !existsSync(r.resolved)) continue;
|
|
20
|
+
try {
|
|
21
|
+
const v = (JSON.parse(readFileSync(r.resolved, "utf8")) as { volume_mm3?: unknown }).volume_mm3;
|
|
22
|
+
if (typeof v === "number") return v;
|
|
23
|
+
} catch {
|
|
24
|
+
// not a manifest
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mass of one build of the unit (sub-units included): stated weights, else CAD volume × density; missing instances listed. */
|
|
32
|
+
export function unitMass(p: Project, u: Unit): SystemMass {
|
|
33
|
+
return systemMass(unitDef(p, u), p.lookup, cadVolume(p));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const grams = (g: number) => (g >= 1000 ? `${num(g / 1000, 3)} kg` : `${num(g, 1)} g`);
|
|
37
|
+
|
|
38
|
+
export interface UnitLink {
|
|
39
|
+
/** Link id, prefixed with the declaring member's path inside the unit. */
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
a: string;
|
|
43
|
+
b: string;
|
|
44
|
+
/** Unit-relative path of the harness that carries it, and its definition. */
|
|
45
|
+
harness?: { path: string; def: ModuleDef };
|
|
46
|
+
mate: boolean;
|
|
47
|
+
torques: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The links made while building one unit: declared by its module and by every member folded into it. */
|
|
51
|
+
export function unitLinks(p: Project, u: Unit): UnitLink[] {
|
|
52
|
+
const out: UnitLink[] = [];
|
|
53
|
+
const declarers: { path: string; def: ModuleDef }[] = [{ path: "", def: unitDef(p, u) }, ...u.members.filter((m) => !m.unit).map((m) => ({ path: m.path, def: p.lookup(m.definition)! }))];
|
|
54
|
+
for (const { path, def } of declarers) {
|
|
55
|
+
if (def.kind === "harness") continue; // a harness's own conductors are made with the harness
|
|
56
|
+
for (const l of def.links ?? []) {
|
|
57
|
+
const end = (e: (typeof l)["a"]) => {
|
|
58
|
+
const child = "child" in e ? e.child : "";
|
|
59
|
+
const at = [path, child].filter(Boolean).join("/");
|
|
60
|
+
const m = u.members.find((x) => x.path === at);
|
|
61
|
+
return `${m?.name ?? (at || def.name)}${e.interfaceId ? ` · ${e.interfaceId}` : ""}`;
|
|
62
|
+
};
|
|
63
|
+
const hp = l.harness ? [path, l.harness].filter(Boolean).join("/") : undefined;
|
|
64
|
+
const hm = hp ? u.members.find((x) => x.path === hp) : undefined;
|
|
65
|
+
const hdef = hm ? p.lookup(hm.definition) : undefined;
|
|
66
|
+
out.push({
|
|
67
|
+
id: [path, l.id].filter(Boolean).join("/"),
|
|
68
|
+
name: l.name ?? l.id,
|
|
69
|
+
a: end(l.a),
|
|
70
|
+
b: end(l.b),
|
|
71
|
+
...(hp && hdef ? { harness: { path: hp, def: hdef } } : {}),
|
|
72
|
+
mate: !!l.mate || !!hdef?.fastenerStack?.length,
|
|
73
|
+
torques: hdef ? jointTorques(hdef, p.lookup).map((t) => `${t.part?.name ?? t.child}: ${num(t.torque.torqueNm, 2)} N·m${t.basis === "assumption" ? " (assumed)" : ""}`) : [],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test plan of one unit (PK5, basic for v0.1): the checks of
|
|
3
|
+
* ./tests.ts as tables by group, and the records to capture, with a CSV
|
|
4
|
+
* to fill in (files/test/test-record.csv, one row per check).
|
|
5
|
+
*/
|
|
6
|
+
import { unitPlan } from "@protoboardai/ext-docs/plan";
|
|
7
|
+
import type { Block, SectionContext, SectionContribution } from "../types.ts";
|
|
8
|
+
import { toCsv } from "../util.ts";
|
|
9
|
+
import { unitTests, type TestGroup, type TestItem } from "./tests.ts";
|
|
10
|
+
|
|
11
|
+
const GROUPS: { group: TestGroup; title: string; intro: string; none: (s: SectionContext) => string | undefined }[] = [
|
|
12
|
+
{
|
|
13
|
+
group: "fai",
|
|
14
|
+
title: "First-article inspection",
|
|
15
|
+
intro: "The first of each part made for this unit, before it is used. Nominal sizes come from the CAD outputs and the model's interfaces; the tolerance is the one on the fabrication order.",
|
|
16
|
+
none: () => "No parts are made for this unit: check purchased parts on receipt against their part numbers (work order, first operation).",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
group: "joint",
|
|
20
|
+
title: "Fits and joints",
|
|
21
|
+
intro: "One check per assembly operation, made at its inspection point (work order) before the next operation covers it up.",
|
|
22
|
+
none: () => "This unit makes no joints.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
group: "bringup",
|
|
26
|
+
title: "Bring-up",
|
|
27
|
+
intro: "In this order, stopping at the first failure, at the point the work order calls for it (before any step that must follow the test, such as fitting propellers). Expected voltages are the design values in the model; currents are the model's supply budget.",
|
|
28
|
+
none: () => "Nothing in this unit is powered on its own; it is tested where it is assembled.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
group: "function",
|
|
32
|
+
title: "Functional checks",
|
|
33
|
+
intro: "One per requirement of the project's brief, on the finished product (the work order's last operation). The method follows the requirement's wording; refine it before testing.",
|
|
34
|
+
none: (s) => (s.unit.kind === "root" ? "The project has no requirements source (docs/brief.md), so no functional checks are generated." : undefined),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export async function testPlanSection(s: SectionContext): Promise<SectionContribution> {
|
|
39
|
+
const { project: p, unit: u } = s;
|
|
40
|
+
const tests = unitTests(p, u, await unitPlan(p, u));
|
|
41
|
+
const blocks: Block[] = [
|
|
42
|
+
{ kind: "note", level: "caution", text: "Basic test plan (v0.1): generated from the model. It lists what to check and the pass criterion the model supports; it does not replace a qualification plan, and nothing here has been run." },
|
|
43
|
+
];
|
|
44
|
+
for (const g of GROUPS) {
|
|
45
|
+
const items = tests.items.filter((t) => t.group === g.group);
|
|
46
|
+
const none = items.length ? undefined : g.none(s);
|
|
47
|
+
if (!items.length && !none) continue;
|
|
48
|
+
blocks.push({ kind: "heading", text: g.title });
|
|
49
|
+
if (!items.length) {
|
|
50
|
+
blocks.push({ kind: "paragraph", text: none! });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const intro = g.group === "bringup" && tests.powerBudget ? `${g.intro} The project's power budget is ${tests.powerBudget}.` : g.group === "function" && tests.requirements ? `${g.intro} Source: ${tests.requirements}.` : g.intro;
|
|
54
|
+
blocks.push({ kind: "paragraph", text: intro, keep: true });
|
|
55
|
+
blocks.push({ kind: "table", columns: [{ key: "id", label: "ID", nowrap: true }, { key: "check", label: "Check" }, { key: "how", label: "How" }, { key: "pass", label: "Pass" }, { key: "record", label: "Record" }], rows: items.map(row) });
|
|
56
|
+
}
|
|
57
|
+
blocks.push({ kind: "heading", text: "Records to capture" });
|
|
58
|
+
blocks.push({
|
|
59
|
+
kind: "list",
|
|
60
|
+
items: [
|
|
61
|
+
`For each unit built: its serial number and the release it was built to (${s.release.version}).`,
|
|
62
|
+
"For each check: pass or fail, the measured value where the check names one, the instrument, who checked and when.",
|
|
63
|
+
"Any deviation from this package, and what was decided.",
|
|
64
|
+
"files/test/test-record.csv has one row per check to fill in; measured results are attached to the release later as evidence.",
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
return { blocks, files: [{ path: "files/test/test-record.csv", content: recordCsv(tests.items, s), role: "test", description: "test record to fill in, one row per check" }] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const row = (t: TestItem) => ({ id: t.id, check: `${t.check}: ${t.subject}`, how: t.how, pass: t.pass, record: t.record });
|
|
71
|
+
|
|
72
|
+
const recordCsv = (items: TestItem[], s: SectionContext) =>
|
|
73
|
+
toCsv(
|
|
74
|
+
["release", "unit", "serial", "id", "group", "check", "subject", "pass_criterion", "result", "measured", "instrument", "by", "date", "note"],
|
|
75
|
+
items.map((t) => ({ release: s.release.version, unit: s.unit.id, serial: "", id: t.id, group: t.group, check: t.check, subject: t.subject, pass_criterion: t.pass, result: "", measured: "", instrument: "", by: "", date: "", note: "" })),
|
|
76
|
+
);
|