Add pure Revit assignment planning with room-wise socket split

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 21:21:36 +01:00
parent e48042bde3
commit d4c44001e1
2 changed files with 826 additions and 0 deletions

View file

@ -0,0 +1,266 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
circuitRefKey,
findRowConflicts,
newRowChoiceKey,
objectGroupKey,
resolveAssignmentPlan,
splitSocketsByRoom,
toAssignmentPlanRequestRows,
type AssignmentPlanCircuit,
type AssignmentPlanObject,
type AssignmentPlanRow,
} from "../src/shared/revit/assignment-plan.js";
function socket(
id: string,
overrides: Partial<AssignmentPlanObject> & { quantity?: number } = {}
): AssignmentPlanObject {
const { quantity = 2, ...rest } = overrides;
return {
id,
worksharingId: `ws-${id}`,
familyAndType: "Steckdose: Doppel",
selectionMarker: "SD",
deviceTypeId: "type-socket",
roomId: "room-1",
roomNumber: "1.01",
roomName: "Büro",
linkedProjectDeviceId: null,
isSocket: true,
planningValues: {
displayName: "Steckdose",
category: "single_phase",
connectionKind: null,
effectiveQuantity: quantity,
powerPerUnitW: 200,
simultaneityFactor: 1,
cosPhi: 0.95,
costGroup: "440",
remark: null,
},
...rest,
};
}
function row(overrides: Partial<AssignmentPlanRow> = {}): AssignmentPlanRow {
return {
id: "row-1",
circuitId: "circuit-1",
roomId: "room-1",
displayName: "Steckdose",
linkedProjectDeviceId: null,
category: "single_phase",
connectionKind: null,
powerPerUnit: 0.2,
simultaneityFactor: 1,
cosPhi: 0.95,
costGroup: "440",
remark: null,
linkedSelectionMarkers: ["SD"],
linkedObjectCount: 2,
...overrides,
};
}
function circuit(overrides: Partial<AssignmentPlanCircuit> = {}): AssignmentPlanCircuit {
return {
id: "circuit-1",
sectionId: "section-2",
category: "single_phase",
equipmentIdentifier: "-2F1",
displayName: "Steckdose",
rows: [row()],
linkedSocketQuantity: 4,
...overrides,
};
}
const sections = [
{ id: "section-1", category: "lighting" as const },
{ id: "section-2", category: "single_phase" as const },
];
describe("Revit assignment plan", () => {
it("splits 8 double sockets with limit 8 into two circuits of 4 objects", () => {
const objects = Array.from({ length: 8 }, (_, index) => socket(`s${index + 1}`));
const result = splitSocketsByRoom(objects, 8);
assert.deepEqual(result.circuits.map((ids) => ids.length), [4, 4]);
assert.deepEqual(result.oversizedObjectIds, []);
assert.deepEqual(result.circuits.flat().sort(), objects.map((object) => object.id).sort());
});
it("keeps rooms together and only splits a room when it exceeds the limit", () => {
const objects = [
...[1, 2].map((index) => socket(`a${index}`, { roomId: "room-a", roomNumber: "1.02" })),
...[1, 2, 3].map((index) => socket(`b${index}`, { roomId: "room-b", roomNumber: "1.10" })),
...[1, 2].map((index) => socket(`c${index}`, { roomId: "room-c", roomNumber: "1.3" })),
...[1, 2, 3, 4, 5].map((index) => socket(`d${index}`, { roomId: "room-d", roomNumber: "2.01" })),
];
// Natural order 1.02 (4) · 1.3 (4) · 1.10 (6) · 2.01 (10)
const result = splitSocketsByRoom(objects, 8);
assert.deepEqual(result.circuits, [
["a1", "a2", "c1", "c2"],
["b1", "b2", "b3"],
["d1", "d2", "d3", "d4"],
["d5"],
]);
});
it("gives an oversized object its own circuit and skips non-sockets", () => {
const result = splitSocketsByRoom(
[
socket("big", { quantity: 10 }),
socket("small", { roomId: "room-2", roomNumber: "1.02" }),
socket("light", { isSocket: false }),
],
8
);
assert.deepEqual(result.circuits, [["big"], ["small"]]);
assert.deepEqual(result.oversizedObjectIds, ["big"]);
assert.deepEqual(result.skippedObjectIds, ["light"]);
assert.throws(() => splitSocketsByRoom([], 0), /positive ganze Zahl/);
});
it("groups single and double sockets of a room into one row and separates rooms", () => {
const single = socket("single", { quantity: 1 });
assert.equal(objectGroupKey(single), objectGroupKey(socket("double")));
const plan = resolveAssignmentPlan({
objects: [single, socket("double"), socket("other-room", { roomId: "room-2" })],
circuits: [],
sections,
plan: {
newCircuits: [{ key: "n1", sectionId: "section-2" }],
assignments: ["single", "double", "other-room"].map((objectId) => ({
objectId,
target: { kind: "new-circuit" as const, key: "n1" },
})),
newRowChoices: [],
},
maxSocketsPerCircuit: 8,
});
assert.equal(plan.circuits.length, 1);
assert.deepEqual(plan.circuits[0]!.rows.map((entry) => entry.quantity), [3, 2]);
assert.equal(plan.circuits[0]!.socketQuantityAfter, 5);
assert.equal(plan.newCircuitCount, 1);
assert.deepEqual(plan.issues, []);
assert.deepEqual(toAssignmentPlanRequestRows(plan).map((entry) => entry.kind), [
"new-row-in-new-circuit",
"new-row-in-new-circuit",
]);
});
it("joins a matching linked row on circuit drop unless a new row is chosen", () => {
const input = {
objects: [socket("s1")],
circuits: [circuit()],
sections,
maxSocketsPerCircuit: 8,
};
const joined = resolveAssignmentPlan({
...input,
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [],
},
});
assert.deepEqual(joined.circuits[0]!.rows[0]!.row, { kind: "existing", rowId: "row-1" });
assert.equal(joined.circuits[0]!.socketQuantityAfter, 6);
const separate = resolveAssignmentPlan({
...input,
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [
newRowChoiceKey({ kind: "existing", circuitId: "circuit-1" }, objectGroupKey(socket("s1"))),
],
},
});
assert.equal(separate.circuits[0]!.rows[0]!.row.kind, "new");
assert.equal(separate.circuits[0]!.rows[0]!.matchingRowId, "row-1");
const manualOnly = resolveAssignmentPlan({
...input,
circuits: [circuit({ rows: [row({ linkedObjectCount: 0, linkedSelectionMarkers: [] })] })],
plan: {
newCircuits: [],
assignments: [{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } }],
newRowChoices: [],
},
});
assert.equal(manualOnly.circuits[0]!.rows[0]!.row.kind, "new");
});
it("lists planning conflicts and blocking issues for an explicit row drop", () => {
const differing = socket("s1", {
planningValues: { ...socket("x").planningValues, powerPerUnitW: 250, simultaneityFactor: null },
});
assert.deepEqual(
findRowConflicts(row({ simultaneityFactor: 0.5 }), differing).map((entry) => entry.field),
["powerPerUnit", "simultaneityFactor"]
);
const plan = resolveAssignmentPlan({
objects: [differing, socket("s2", { roomId: "room-2", selectionMarker: "X" })],
circuits: [circuit()],
sections,
plan: {
newCircuits: [],
assignments: [
{ objectId: "s1", target: { kind: "row", rowId: "row-1" } },
{ objectId: "s2", target: { kind: "row", rowId: "row-1" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: null,
});
assert.deepEqual(plan.conflictObjectIds, ["s1"]);
assert.ok(plan.issues.some((issue) => /Raum/.test(issue)));
assert.ok(plan.issues.some((issue) => /Auswahlkenner/.test(issue)));
assert.equal(plan.circuits[0]!.overLimit, false);
});
it("marks only circuits that gain sockets above the limit and rejects wrong categories", () => {
const plan = resolveAssignmentPlan({
objects: [socket("s1"), socket("s2"), socket("s3", { quantity: 4 })],
circuits: [circuit()],
sections,
plan: {
newCircuits: [{ key: "light", sectionId: "section-1" }],
assignments: [
{ objectId: "s1", target: { kind: "circuit", circuitId: "circuit-1" } },
{ objectId: "s2", target: { kind: "circuit", circuitId: "circuit-1" } },
{ objectId: "s3", target: { kind: "new-circuit", key: "light" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: 6,
});
assert.deepEqual(plan.overLimitRefKeys, ["circuit-1"]);
assert.equal(plan.circuits[0]!.socketQuantityAfter, 8);
assert.equal(circuitRefKey(plan.circuits[1]!.circuit), "new:light");
assert.ok(plan.issues.some((issue) => /Kategorie/.test(issue)));
});
it("reports duplicate and unknown objects", () => {
const plan = resolveAssignmentPlan({
objects: [socket("s1")],
circuits: [],
sections,
plan: {
newCircuits: [{ key: "n1", sectionId: "section-2" }],
assignments: [
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
{ objectId: "s1", target: { kind: "new-circuit", key: "n1" } },
{ objectId: "gone", target: { kind: "new-circuit", key: "n1" } },
],
newRowChoices: [],
},
maxSocketsPerCircuit: 8,
});
assert.equal(plan.objectCount, 1);
assert.equal(plan.issues.length, 2);
});
});