Initial project import
This commit is contained in:
885
src/validation/rules.ts
Normal file
885
src/validation/rules.ts
Normal file
@@ -0,0 +1,885 @@
|
||||
import type { PlacedPart } from '../types';
|
||||
import type { Finding, RuleContext, ValidationRule } from './types';
|
||||
import { approxAABB, isExitPart, isPumpOff, partName } from './graph';
|
||||
import { computeReservoirWaterStats } from '../simulation/flowSimulator';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { dist3 } from '../utils/connectors';
|
||||
|
||||
/**
|
||||
* Design-check rules. Each rule is a pure function over a RuleContext and is
|
||||
* registered in `ALL_RULES` at the bottom — add new rules there.
|
||||
*
|
||||
* Defensive style: sibling agents may evolve the simulator/part shapes, so
|
||||
* everything reads via optional chaining and tolerates missing data.
|
||||
*/
|
||||
|
||||
const fmt = (n: number, digits = 1) => Number(n.toFixed(digits)).toString();
|
||||
|
||||
/** Fraction of max head above which a pump is "near its limit". */
|
||||
export const HEAD_MARGIN_RATIO = 0.8;
|
||||
/** A flow path that descends this many feet after a climb risks siphoning. */
|
||||
export const SIPHON_DROP_FT = 2;
|
||||
/** Horizontal pipes above this height (ft) need support. */
|
||||
export const SPAN_MIN_HEIGHT_FT = 1.5;
|
||||
/** Horizontal pipes longer than this (ft) need support. */
|
||||
export const SPAN_MIN_LENGTH_FT = 4;
|
||||
/** A support must reach within this distance (ft) beneath a pipe end. */
|
||||
export const SUPPORT_REACH_FT = 0.6;
|
||||
|
||||
// ---------- a. pump head margin ----------
|
||||
|
||||
function getPumpStaticHead(ctx: RuleContext, pumpId: string): number {
|
||||
const pump = ctx.partsMap[pumpId];
|
||||
if (!pump) return 0;
|
||||
const conns = ctx.graph.connectorsByPart.get(pumpId) ?? [];
|
||||
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
|
||||
if (!outlet) return 0;
|
||||
|
||||
let maxY = pump.position[1];
|
||||
const queue = [outlet.partId];
|
||||
const visited = new Set<string>([pumpId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currId = queue.shift()!;
|
||||
if (visited.has(currId)) continue;
|
||||
visited.add(currId);
|
||||
|
||||
const part = ctx.partsMap[currId];
|
||||
if (part) {
|
||||
maxY = Math.max(maxY, part.position[1]);
|
||||
}
|
||||
|
||||
const nbs = ctx.graph.neighbors.get(currId);
|
||||
if (nbs) {
|
||||
for (const nb of nbs) {
|
||||
if (!visited.has(nb)) {
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, maxY - pump.position[1]);
|
||||
}
|
||||
|
||||
function pumpHeadMargin(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pr of ctx.sim?.pumps ?? []) {
|
||||
const pump = ctx.partsMap[pr?.pumpId ?? ''];
|
||||
if (!pump || isPumpOff(pump)) continue;
|
||||
const maxHead = pr.maxHeadFt ?? 0;
|
||||
const head = getPumpStaticHead(ctx, pump.id);
|
||||
if (maxHead <= 0) continue;
|
||||
const ratio = head / maxHead;
|
||||
if (ratio <= HEAD_MARGIN_RATIO) continue;
|
||||
|
||||
const name = partName(pump);
|
||||
// Lowering the high point by this much restores a 20% margin.
|
||||
const lowerBy = Math.max(0.1, head - HEAD_MARGIN_RATIO * maxHead);
|
||||
const fix = `Choose a higher-head pump or lower the highest point by ${fmt(lowerBy)} ft`;
|
||||
if (ratio >= 1) {
|
||||
findings.push({
|
||||
id: `pump-head:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump cannot reach the highest point',
|
||||
detail: `${name} must lift water ${fmt(head)} ft but its max head is ${fmt(maxHead)} ft — it delivers no flow.`,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
id: `pump-head:${pump.id}`,
|
||||
severity: 'warning',
|
||||
title: `Pump near its limit (${Math.round(ratio * 100)}%), keep \u226520% margin`,
|
||||
detail: `${name} is lifting ${fmt(head)} ft of ${fmt(maxHead)} ft max head, so flow is heavily reduced and the pump will wear quickly.`,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
// ---------- b. pump starved / off ----------
|
||||
|
||||
/** BFS the part graph from the pump inlet (never through the pump) for a reservoir. */
|
||||
function inletReachesReservoir(ctx: RuleContext, pumpId: string, inletKey: string): boolean {
|
||||
const start: string[] = [];
|
||||
for (const { a, b } of ctx.graph.joints) {
|
||||
if (a.key === inletKey) start.push(b.partId);
|
||||
if (b.key === inletKey) start.push(a.partId);
|
||||
}
|
||||
const seen = new Set<string>([pumpId, ...start]);
|
||||
const queue = [...start];
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (ctx.partsMap[id]?.type === 'reservoir') return true;
|
||||
for (const nb of ctx.graph.neighbors.get(id) ?? []) {
|
||||
if (!seen.has(nb)) {
|
||||
seen.add(nb);
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pumpStarvedOrOff(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pump of ctx.parts) {
|
||||
if (pump.type !== 'pump') continue;
|
||||
const name = partName(pump);
|
||||
|
||||
if (isPumpOff(pump)) {
|
||||
findings.push({
|
||||
id: `pump-dead:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump is switched off',
|
||||
detail: `${name} is powered off, so it delivers no flow.`,
|
||||
partIds: [pump.id],
|
||||
fix: 'Turn the pump on (power toggle in the inspector).',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const conns = ctx.graph.connectorsByPart.get(pump.id) ?? [];
|
||||
const inlet = conns.find((c) => c.connectorId === 'in') ?? conns[0];
|
||||
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
|
||||
|
||||
const hasOutlet = outlet && ctx.graph.joinedKeys.has(outlet.key);
|
||||
const hasInlet = inlet && ctx.graph.joinedKeys.has(inlet.key);
|
||||
|
||||
const pr = ctx.sim?.pumps?.find((p) => p?.pumpId === pump.id);
|
||||
if (pr && (pr.gph ?? 0) > 0 && hasOutlet && hasInlet) continue;
|
||||
// Over-head pumps are owned by the head-margin rule — don't double report.
|
||||
const staticHead = getPumpStaticHead(ctx, pump.id);
|
||||
if (pr && (pr.maxHeadFt ?? 0) > 0 && staticHead >= (pr.maxHeadFt ?? 0)) continue;
|
||||
|
||||
let detail: string;
|
||||
let fix: string;
|
||||
if (inlet && !ctx.graph.joinedKeys.has(inlet.key)) {
|
||||
detail = `${name}'s inlet is not connected to anything — there is no water source.`;
|
||||
fix = 'Connect the pump inlet to a reservoir.';
|
||||
} else if (inlet && !inletReachesReservoir(ctx, pump.id, inlet.key)) {
|
||||
detail = `${name}'s inlet line never reaches a reservoir — there is no water source.`;
|
||||
fix = 'Route the inlet line back to a reservoir.';
|
||||
} else if (outlet && !ctx.graph.joinedKeys.has(outlet.key)) {
|
||||
detail = `${name}'s outlet is not connected to anything — water has nowhere to go.`;
|
||||
fix = 'Connect the pump outlet to your plumbing.';
|
||||
} else {
|
||||
const simMsg = ctx.sim?.warnings?.find(
|
||||
(w) => w?.partId === pump.id && w?.level !== 'info',
|
||||
)?.message;
|
||||
detail = simMsg ?? `${name} delivers no flow.`;
|
||||
fix = 'Check for closed valves and verify the line reaches an outlet.';
|
||||
}
|
||||
|
||||
findings.push({
|
||||
id: `pump-dead:${pump.id}`,
|
||||
severity: 'error',
|
||||
title: 'Pump delivers no flow',
|
||||
detail,
|
||||
partIds: [pump.id],
|
||||
fix,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- c. mismatched diameters ----------
|
||||
|
||||
function getConnectorDiameter(part: PlacedPart, connId: string): number {
|
||||
if (part.type === 'reducer' || part.type === 'reducingElbow') {
|
||||
return connId === 'a' ? (part.params.diameterA ?? 1.5) : (part.params.diameterB ?? 1.0);
|
||||
}
|
||||
if (part.type === 'reducingTee') {
|
||||
return connId === 'c' || connId === 'branch' ? (part.params.diameterB ?? 1.0) : (part.params.diameterA ?? 1.5);
|
||||
}
|
||||
return part.params.diameter ?? 1.0;
|
||||
}
|
||||
|
||||
function mismatchedDiameters(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const { a, b } of ctx.graph.joints) {
|
||||
const pa = ctx.partsMap[a.partId];
|
||||
const pb = ctx.partsMap[b.partId];
|
||||
if (!pa || !pb) continue;
|
||||
const da = getConnectorDiameter(pa, a.connectorId);
|
||||
const db = getConnectorDiameter(pb, b.connectorId);
|
||||
if (Math.abs(da - db) < 0.01) continue;
|
||||
|
||||
const pairKey = [a.partId, b.partId].sort().join(':');
|
||||
if (seen.has(pairKey)) continue;
|
||||
seen.add(pairKey);
|
||||
|
||||
const nameA = partName(pa);
|
||||
const nameB = partName(pb);
|
||||
findings.push({
|
||||
id: `diameter-mismatch:${pairKey}`,
|
||||
severity: 'warning',
|
||||
title: 'Mismatched pipe diameters at a joint',
|
||||
detail: `${nameA} (${fmt(da, 2)} in) is joined to ${nameB} (${fmt(db, 2)} in) — the abrupt size change causes turbulence and pressure loss.`,
|
||||
partIds: [a.partId, b.partId],
|
||||
fix: `Add a reducer or match diameters (${nameA} is ${fmt(da, 2)} in, ${nameB} is ${fmt(db, 2)} in)`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- d. dead legs ----------
|
||||
|
||||
function deadLegs(ctx: RuleContext): Finding[] {
|
||||
const flows = ctx.sim?.flows ?? {};
|
||||
const flowing = new Set(Object.keys(flows).filter((id) => (flows[id] ?? 0) > 0));
|
||||
if (!flowing.size) return []; // nothing is flowing — orphan/pump rules apply instead
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const part of ctx.parts) {
|
||||
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
|
||||
if (!conns.length) continue;
|
||||
if (part.type === 'pump' || isExitPart(part)) continue;
|
||||
const joinedCount = conns.filter((c) => ctx.graph.joinedKeys.has(c.key)).length;
|
||||
// Terminal = attached on one side but with an open (capped/dangling) end.
|
||||
if (joinedCount === 0 || joinedCount === conns.length) continue;
|
||||
|
||||
// Walk back through the plain-conduit chain to where the branch attaches.
|
||||
const chain = [part.id];
|
||||
let prev: string | null = null;
|
||||
let cur = part.id;
|
||||
let attached: string | null = null;
|
||||
for (let guard = 0; guard < ctx.parts.length; guard++) {
|
||||
const nbrs = [...(ctx.graph.neighbors.get(cur) ?? [])].filter(
|
||||
(n) => n !== prev && !chain.includes(n),
|
||||
);
|
||||
if (nbrs.length !== 1) break;
|
||||
const next = nbrs[0];
|
||||
const nextPart = ctx.partsMap[next];
|
||||
const isJunction = (ctx.graph.neighbors.get(next)?.size ?? 0) >= 3;
|
||||
if (!nextPart || nextPart.type === 'pump' || isExitPart(nextPart) || isJunction) {
|
||||
attached = next;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
prev = cur;
|
||||
cur = next;
|
||||
}
|
||||
|
||||
const branchFlows =
|
||||
(attached !== null && flowing.has(attached)) || chain.some((id) => flowing.has(id));
|
||||
if (!branchFlows) continue;
|
||||
if (chain.some((id) => isExitPart(ctx.partsMap[id]))) continue;
|
||||
|
||||
findings.push({
|
||||
id: `dead-leg:${part.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Dead leg — stagnant water',
|
||||
detail: `${partName(part)} ends a flowing branch with no reservoir, emitter, drain or grow part — water will sit stagnant (or leak) here.`,
|
||||
partIds: chain,
|
||||
fix: 'Cap with an emitter/drain or remove',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- e. unsupported spans ----------
|
||||
|
||||
function unsupportedSpans(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pipe of ctx.parts) {
|
||||
if (pipe.type !== 'pipe') continue;
|
||||
const ends = ctx.graph.connectorsByPart.get(pipe.id) ?? [];
|
||||
if (ends.length < 2) continue;
|
||||
const [ea, eb] = [ends[0].pos, ends[ends.length - 1].pos];
|
||||
if (Math.abs(ea[1] - eb[1]) > 0.3) continue; // not horizontal
|
||||
const centerY = (ea[1] + eb[1]) / 2;
|
||||
if (centerY <= SPAN_MIN_HEIGHT_FT) continue;
|
||||
const length = pipe.params?.length ?? Math.hypot(eb[0] - ea[0], eb[2] - ea[2]);
|
||||
if (length <= SPAN_MIN_LENGTH_FT) continue;
|
||||
|
||||
const supported = (end: [number, number, number]) =>
|
||||
ctx.parts.some((q) => {
|
||||
if (q.id === pipe.id) return false;
|
||||
const box = approxAABB(q, ctx.graph.connectorsByPart.get(q.id) ?? []);
|
||||
const margin = 0.3;
|
||||
const horizontallyUnder =
|
||||
end[0] >= box.min[0] - margin &&
|
||||
end[0] <= box.max[0] + margin &&
|
||||
end[2] >= box.min[2] - margin &&
|
||||
end[2] <= box.max[2] + margin;
|
||||
// The support must occupy the space just beneath the end: reach up to
|
||||
// within SUPPORT_REACH_FT of the pipe while extending from below it.
|
||||
return (
|
||||
horizontallyUnder &&
|
||||
box.max[1] >= end[1] - SUPPORT_REACH_FT &&
|
||||
box.min[1] <= end[1] - SUPPORT_REACH_FT
|
||||
);
|
||||
});
|
||||
|
||||
if (supported(ea) || supported(eb)) continue;
|
||||
|
||||
findings.push({
|
||||
id: `unsupported-span:${pipe.id}`,
|
||||
severity: 'info',
|
||||
title: `Unsupported span ${fmt(length)} ft`,
|
||||
detail: `${partName(pipe)} runs ${fmt(length)} ft at ${fmt(centerY)} ft height with nothing beneath either end — it will sag over time.`,
|
||||
partIds: [pipe.id],
|
||||
fix: 'Add a lattice/structure support',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- f. siphon risk ----------
|
||||
|
||||
function siphonRisk(ctx: RuleContext): Finding[] {
|
||||
const flows = ctx.sim?.flows ?? {};
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const pr of ctx.sim?.pumps ?? []) {
|
||||
if ((pr?.gph ?? 0) <= 0) continue;
|
||||
const pumpId = pr.pumpId;
|
||||
const startId = ctx.graph.nodeOfConnector.get(`${pumpId}:out`);
|
||||
const startNode = startId ? ctx.graph.nodes.get(startId) : undefined;
|
||||
if (!startNode) continue;
|
||||
|
||||
const startY = startNode.pos[1];
|
||||
let bestDrop = 0;
|
||||
let bestHighPart = pumpId;
|
||||
let bestHighY = startY;
|
||||
|
||||
const visited = new Set<string>([`P:${pumpId}`, startNode.id]);
|
||||
const stack: { id: string; pathMax: number; highPart: string }[] = [
|
||||
{ id: startNode.id, pathMax: startY, highPart: pumpId },
|
||||
];
|
||||
while (stack.length) {
|
||||
const { id, pathMax, highPart } = stack.pop()!;
|
||||
const node = ctx.graph.nodes.get(id);
|
||||
if (!node) continue;
|
||||
for (const e of node.edges) {
|
||||
if (visited.has(e.to)) continue;
|
||||
if ((flows[e.partId] ?? 0) <= 0 && e.partId !== pumpId) continue;
|
||||
visited.add(e.to);
|
||||
const to = ctx.graph.nodes.get(e.to);
|
||||
if (!to) continue;
|
||||
let max = pathMax;
|
||||
let nextHigh = highPart;
|
||||
if (to.pos[1] > max) {
|
||||
max = to.pos[1];
|
||||
nextHigh = e.partId;
|
||||
}
|
||||
const drop = max - to.pos[1];
|
||||
if (max > startY + 0.5 && drop > bestDrop) {
|
||||
bestDrop = drop;
|
||||
bestHighPart = nextHigh;
|
||||
bestHighY = max;
|
||||
}
|
||||
stack.push({ id: e.to, pathMax: max, highPart: nextHigh });
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDrop >= SIPHON_DROP_FT) {
|
||||
findings.push({
|
||||
id: `siphon:${pumpId}`,
|
||||
severity: 'info',
|
||||
title: 'Possible siphon on shutdown',
|
||||
detail: `Flow from ${partName(ctx.partsMap[pumpId])} climbs to ${fmt(bestHighY)} ft and then descends ${fmt(bestDrop)} ft — when the pump stops, gravity can keep siphoning water through the line.`,
|
||||
partIds: [bestHighPart],
|
||||
fix: 'Add an air gap or check valve at the high point',
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- g. undersized drains ----------
|
||||
|
||||
function undersizedDrains(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const part of ctx.parts) {
|
||||
const isDrain = part.type === 'drain' || typeof part.params?.capacityGph === 'number';
|
||||
if (!isDrain) continue;
|
||||
const capacity = part.params?.capacityGph ?? 250;
|
||||
// Prefer the sim's pre-clamp inflow report; fall back to the flow map.
|
||||
const reported = ctx.sim?.drains?.find?.((d) => d?.partId === part.id)?.inflowGph;
|
||||
const inflow = reported ?? ctx.sim?.flows?.[part.id] ?? 0;
|
||||
if (inflow <= capacity + 0.5) continue;
|
||||
|
||||
const needed = Math.ceil(inflow / 25) * 25;
|
||||
findings.push({
|
||||
id: `drain-capacity:${part.id}`,
|
||||
severity: 'error',
|
||||
title: 'Undersized drain — will overflow',
|
||||
detail: `${partName(part)} receives ${Math.round(inflow)} GPH but is rated for ${Math.round(capacity)} GPH — the excess will back up and flood.`,
|
||||
partIds: [part.id],
|
||||
fix: `Upsize drain to \u2265 ${needed} GPH capacity`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- h. net-pot spill risk ----------
|
||||
|
||||
function netPotSpills(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const pot of ctx.sim?.netPots ?? []) {
|
||||
if (!pot?.overflowing) continue;
|
||||
const part = ctx.partsMap[pot.partId];
|
||||
if (!part) continue;
|
||||
const source = ctx.partsMap[pot.sourcePartId];
|
||||
findings.push({
|
||||
id: `net-pot-spill:${pot.partId}`,
|
||||
severity: 'error',
|
||||
title: 'Net pot will overflow from backed-up plumbing',
|
||||
detail: `${partName(part)} sits above ${partName(source)}. Water is rising to ${fmt(pot.waterHeightFt)} ft while the pot rim is ${fmt(pot.rimHeightFt)} ft, so nutrient solution will spill out of the top.`,
|
||||
partIds: source ? [pot.partId, source.id] : [pot.partId],
|
||||
fix: 'Add a drain/outlet, lower water level, or move the net pot off the flooded line.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- i. orphans ----------
|
||||
|
||||
function orphans(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const part of ctx.parts) {
|
||||
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
|
||||
if (!conns.length) continue; // decorative/structural parts can't be orphans
|
||||
if (conns.some((c) => ctx.graph.joinedKeys.has(c.key))) continue;
|
||||
findings.push({
|
||||
id: `orphan:${part.id}`,
|
||||
severity: 'info',
|
||||
title: 'Not connected to anything',
|
||||
detail: `${partName(part)} has no connections — it is not part of any water circuit.`,
|
||||
partIds: [part.id],
|
||||
fix: 'Drag it onto a matching connector, or delete it if unused.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- j. closed valves ----------
|
||||
|
||||
function closedValves(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const valve of ctx.parts) {
|
||||
if (valve.type !== 'valve' || valve.params?.open !== 0) continue;
|
||||
const compIdx = ctx.graph.componentOf.get(valve.id);
|
||||
if (compIdx === undefined) continue;
|
||||
const comp = ctx.graph.components[compIdx];
|
||||
const hasLivePump = [...comp].some((id) => {
|
||||
const p: PlacedPart | undefined = ctx.partsMap[id];
|
||||
return p?.type === 'pump' && !isPumpOff(p);
|
||||
});
|
||||
if (!hasLivePump) continue;
|
||||
findings.push({
|
||||
id: `closed-valve:${valve.id}`,
|
||||
severity: 'info',
|
||||
title: 'Closed valve blocking a flowing line',
|
||||
detail: `${partName(valve)} is fully closed — everything beyond it gets no water.`,
|
||||
partIds: [valve.id],
|
||||
fix: 'Open the valve, or remove it if the branch is unused.',
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- k. environment / room planning ----------
|
||||
|
||||
function roomEnvironment(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const room of ctx.roomMetrics) {
|
||||
const roomPart = ctx.partsMap[room.roomId];
|
||||
const width = roomPart?.params.width ?? 0;
|
||||
const depth = roomPart?.params.depth ?? 0;
|
||||
const canopyArea = width * depth;
|
||||
|
||||
if (!room.sealed && room.co2TankCount > 0) {
|
||||
findings.push({
|
||||
id: `room-co2-open:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'CO2 enrichment wasted in open-air room',
|
||||
detail: `${room.label} is marked open air but has ${room.co2TankCount} CO2 tank${room.co2TankCount === 1 ? '' : 's'} — outside air exchange will purge enrichment before plants can use it.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Seal the room or remove CO2 hardware.',
|
||||
});
|
||||
}
|
||||
|
||||
if (room.sealed && room.airChangesPerMinute > 1) {
|
||||
findings.push({
|
||||
id: `room-sealed-exhaust:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Sealed room exhausting too aggressively',
|
||||
detail: `${room.label} is marked sealed but exhausts ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — that behaves more like an open-air room and dumps conditioned CO2-rich air.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Reduce exhaust CFM or switch the room to open air.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!room.sealed && room.airChangesPerMinute < 0.5) {
|
||||
findings.push({
|
||||
id: `room-vent-low:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'Low air exchange for open-air room',
|
||||
detail: `${room.label} exchanges only ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — heat and humidity can linger unless intake/exhaust is stronger.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Increase exhaust capacity or reduce room volume.',
|
||||
});
|
||||
}
|
||||
|
||||
if (room.sealed && room.co2TankCount === 0 && room.lightWatts >= 600) {
|
||||
findings.push({
|
||||
id: `room-co2-missing:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'High-light sealed room without CO2',
|
||||
detail: `${room.label} is sealed and carries ${Math.round(room.lightWatts)} W of lighting but no CO2 source — the room can be enriched, but currently cannot take advantage of the sealed configuration.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add a CO2 tank or switch to open-air ventilation.',
|
||||
});
|
||||
}
|
||||
|
||||
if (canopyArea > 0 && room.lightWatts > 0) {
|
||||
const wattsPerFt2 = room.lightWatts / canopyArea;
|
||||
if (wattsPerFt2 < 25) {
|
||||
findings.push({
|
||||
id: `room-light-low:${room.roomId}`,
|
||||
severity: 'info',
|
||||
title: 'Low lighting density',
|
||||
detail: `${room.label} provides ${wattsPerFt2.toFixed(1)} W/ft2 across ${canopyArea.toFixed(1)} ft2 — useful for propagation or low-light crops, but light-hungry plants will underperform.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add more fixture wattage or reduce active canopy area.',
|
||||
});
|
||||
} else if (wattsPerFt2 > 55) {
|
||||
findings.push({
|
||||
id: `room-light-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Very high lighting density',
|
||||
detail: `${room.label} is carrying ${wattsPerFt2.toFixed(1)} W/ft2 — that is intense enough to demand strong cooling, CO2 strategy, and careful canopy distance management.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Raise fixtures, reduce wattage, or tighten environmental control.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// New environment/lighting findings
|
||||
if (room.lightWatts > 0) {
|
||||
if (room.roomDLI < 10) {
|
||||
findings.push({
|
||||
id: `room-dli-low:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Daily Light Integral (DLI) is too low',
|
||||
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI < 10 is insufficient for healthy growth and will cause the crop to stretch.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Increase grow lights wattage, increase photoperiod (hours on), or decrease room/canopy area.',
|
||||
});
|
||||
} else if (room.roomDLI > 45) {
|
||||
findings.push({
|
||||
id: `room-dli-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Daily Light Integral (DLI) is too high',
|
||||
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI > 45 risks phototoxic bleaching and leaf tissue damage.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Reduce grow lights wattage, decrease photoperiod (hours on), or raise the fixtures.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (room.sealed && room.lightWatts > 400 && room.roomCo2 === 150) {
|
||||
findings.push({
|
||||
id: `room-co2-starved:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Sealed room CO₂ starvation risk',
|
||||
detail: `${room.label} is sealed and runs high-wattage lighting (${room.lightWatts.toFixed(0)} W > 400 W), but CO₂ levels drop to 150 PPM due to lack of a CO₂ tank.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Add a CO₂ tank or switch to open-air ventilation.',
|
||||
});
|
||||
}
|
||||
|
||||
const growthStage = useBuilder.getState().growthStage ?? 'vegetative';
|
||||
let minVpd = 0.8;
|
||||
let maxVpd = 1.6;
|
||||
if (growthStage === 'seedling') {
|
||||
minVpd = 0.4;
|
||||
maxVpd = 0.8;
|
||||
} else if (growthStage === 'vegetative') {
|
||||
minVpd = 0.8;
|
||||
maxVpd = 1.2;
|
||||
} else if (growthStage === 'flowering') {
|
||||
minVpd = 1.2;
|
||||
maxVpd = 1.6;
|
||||
}
|
||||
|
||||
if (room.roomVPD < minVpd) {
|
||||
findings.push({
|
||||
id: `room-vpd-low:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Vapor Pressure Deficit (VPD) is too low',
|
||||
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is below the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. Low VPD slows transpiration and can cause nutrient deficiencies.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Raise the temperature, lower relative humidity, or increase exhaust ventilation.',
|
||||
});
|
||||
} else if (room.roomVPD > maxVpd) {
|
||||
findings.push({
|
||||
id: `room-vpd-high:${room.roomId}`,
|
||||
severity: 'warning',
|
||||
title: 'Vapor Pressure Deficit (VPD) is too high',
|
||||
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is above the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. High VPD causes excessive transpiration, leading to plant stress and wilting.`,
|
||||
partIds: [room.roomId],
|
||||
fix: 'Lower the temperature, increase relative humidity, or reduce lighting intensity.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------- registry ----------
|
||||
|
||||
/** All design-check rules, in execution order. Add new rules here. */
|
||||
export const ALL_RULES: ValidationRule[] = [
|
||||
{
|
||||
id: 'pump-head',
|
||||
description: 'Pumps should keep ≥20% head margin below their max head.',
|
||||
run: pumpHeadMargin,
|
||||
},
|
||||
{
|
||||
id: 'pump-dead',
|
||||
description: 'Pumps that are off, starved of source water, or dead-ended.',
|
||||
run: pumpStarvedOrOff,
|
||||
},
|
||||
{
|
||||
id: 'diameter-mismatch',
|
||||
description: 'Joined connectors whose parts have different pipe diameters.',
|
||||
run: mismatchedDiameters,
|
||||
},
|
||||
{
|
||||
id: 'dead-leg',
|
||||
description: 'Flowing branches that terminate with no exit part (stagnation).',
|
||||
run: deadLegs,
|
||||
},
|
||||
{
|
||||
id: 'unsupported-span',
|
||||
description: 'Long elevated horizontal pipes with nothing beneath their ends.',
|
||||
run: unsupportedSpans,
|
||||
},
|
||||
{
|
||||
id: 'siphon',
|
||||
description: 'Flow paths that climb then descend ≥2 ft (siphon on shutdown).',
|
||||
run: siphonRisk,
|
||||
},
|
||||
{
|
||||
id: 'drain-capacity',
|
||||
description: 'Drains receiving more flow than their rated capacity.',
|
||||
run: undersizedDrains,
|
||||
},
|
||||
{
|
||||
id: 'net-pot-spill',
|
||||
description: 'Net pots positioned over backed-up wet lines that will spill from the rim.',
|
||||
run: netPotSpills,
|
||||
},
|
||||
{
|
||||
id: 'orphan',
|
||||
description: 'Flow-capable parts with no connections at all.',
|
||||
run: orphans,
|
||||
},
|
||||
{
|
||||
id: 'closed-valve',
|
||||
description: 'Fully closed valves on an otherwise powered circuit.',
|
||||
run: closedValves,
|
||||
},
|
||||
{
|
||||
id: 'room-environment',
|
||||
description: 'Grow-room ventilation, sealed/open-air behavior, CO2 use, and light density.',
|
||||
run: roomEnvironment,
|
||||
},
|
||||
{
|
||||
id: 'reservoir-chemistry',
|
||||
description: 'Reservoir temperature, pH, and EC/nutrient concentration values.',
|
||||
run: reservoirWaterChemistry,
|
||||
},
|
||||
{
|
||||
id: 'part-overlap',
|
||||
description: 'Parts that are overlapping or morphing into one another.',
|
||||
run: partOverlaps,
|
||||
},
|
||||
];
|
||||
|
||||
function reservoirWaterChemistry(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const reservoirs = ctx.parts.filter((p) => p.type === 'reservoir');
|
||||
|
||||
for (const res of reservoirs) {
|
||||
const stats = computeReservoirWaterStats(res, ctx.partsMap);
|
||||
const runtime = ctx.sim?.reservoirRuntime?.[res.id];
|
||||
|
||||
if (runtime?.empty) {
|
||||
findings.push({
|
||||
id: `res-empty:${res.id}`,
|
||||
severity: 'error',
|
||||
title: 'Reservoir runs dry under current load',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} is fully depleted by the current pump/drain demand, so downstream flow stops entirely.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Increase reservoir volume, reduce draw rate, or close the loop with return flow.',
|
||||
});
|
||||
} else if (runtime && runtime.minutesRemaining !== null && runtime.minutesRemaining < 30) {
|
||||
findings.push({
|
||||
id: `res-runtime-low:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Reservoir runtime is too short',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} has only ${runtime.minutesRemaining.toFixed(1)} minutes of water left at the current net draw of ${runtime.netOutflowGph.toFixed(1)} GPH.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Increase source volume, reduce pump flow, or return more water to this reservoir.',
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Water Temperature check
|
||||
if (stats.tempF > 73.0) {
|
||||
findings.push({
|
||||
id: `res-temp-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Reservoir water temperature too high',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} water temperature is ${stats.tempF.toFixed(1)}°F — water above 73°F holds significantly less dissolved oxygen and increases Pythium (root rot) risks.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add a water chiller, reduce room ambient temperature, or turn off unneeded inline pumps.',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. pH check
|
||||
if (stats.ph < 5.5) {
|
||||
findings.push({
|
||||
id: `res-ph-low:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Water pH is too low (acidic)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH below 5.5 locks out Calcium, Magnesium, and Phosphorus, starving the plant.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add pH Up (potassium hydroxide) or dilute with fresh water to raise pH to 5.8 - 6.2.',
|
||||
});
|
||||
} else if (stats.ph > 6.5) {
|
||||
findings.push({
|
||||
id: `res-ph-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'Water pH is too high (alkaline)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH above 6.5 locks out Iron, Manganese, Boron, and Zinc, causing leaf chlorosis.`,
|
||||
partIds: [res.id],
|
||||
fix: 'Add pH Down (phosphoric/citric acid) to lower pH to 5.8 - 6.2.',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. EC check
|
||||
const growthStage = useBuilder.getState().growthStage;
|
||||
let ecLow = 1.0;
|
||||
let ecHigh = 1.6;
|
||||
let fixLow = 'Add more concentrated nutrient solution (part A/B) to raise EC to 1.0 - 1.6 mS/cm.';
|
||||
let fixHigh = 'Dilute the reservoir with fresh water to reduce nutrient concentration (EC).';
|
||||
|
||||
if (growthStage === 'seedling') {
|
||||
ecLow = 0.4;
|
||||
ecHigh = 0.8;
|
||||
fixLow = 'Add dilute nutrient solution to raise EC to 0.4 - 0.8 mS/cm for seedlings.';
|
||||
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 0.4 - 0.8 mS/cm for seedlings.';
|
||||
} else if (growthStage === 'flowering') {
|
||||
ecLow = 1.5;
|
||||
ecHigh = 2.2;
|
||||
fixLow = 'Add bloom nutrients to raise EC to 1.5 - 2.2 mS/cm for flowering.';
|
||||
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 1.5 - 2.2 mS/cm for flowering.';
|
||||
}
|
||||
|
||||
if (stats.ec < ecLow) {
|
||||
findings.push({
|
||||
id: `res-ec-low:${res.id}`,
|
||||
severity: 'info',
|
||||
title: 'Low nutrient concentration (EC)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — very low nutrient levels will limit growth rate and lead to deficiencies in the ${growthStage} stage.`,
|
||||
partIds: [res.id],
|
||||
fix: fixLow,
|
||||
});
|
||||
} else if (stats.ec > ecHigh) {
|
||||
findings.push({
|
||||
id: `res-ec-high:${res.id}`,
|
||||
severity: 'warning',
|
||||
title: 'High nutrient concentration (EC)',
|
||||
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — EC above ${ecHigh.toFixed(1)} risks nutrient burn or root dehydration in the ${growthStage} stage.`,
|
||||
partIds: [res.id],
|
||||
fix: fixHigh,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function partOverlaps(ctx: RuleContext): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < ctx.parts.length; i++) {
|
||||
const p1 = ctx.parts[i];
|
||||
|
||||
for (let j = i + 1; j < ctx.parts.length; j++) {
|
||||
const p2 = ctx.parts[j];
|
||||
|
||||
// Calculate distance between centers
|
||||
const dist = dist3(p1.position, p2.position);
|
||||
|
||||
// If centers are extremely close (e.g. < 0.15 ft) and they occupy the same space
|
||||
if (dist < 0.15) {
|
||||
const key = [p1.id, p2.id].sort().join(':');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
findings.push({
|
||||
id: `part-overlap:${key}`,
|
||||
severity: 'warning',
|
||||
title: 'Overlapping parts (clipping)',
|
||||
detail: `${partName(p1)} and ${partName(p2)} are occupying the same space and morphing into each other.`,
|
||||
partIds: [p1.id, p2.id],
|
||||
fix: 'Delete the duplicate part or move them apart',
|
||||
});
|
||||
} else if (p1.type === 'pipe' && p2.type === 'pipe') {
|
||||
// Check for partial collinear overlap of two parallel pipes
|
||||
const yaw1 = p1.rotation[1];
|
||||
const yaw2 = p2.rotation[1];
|
||||
const angleDiff = Math.abs(Math.atan2(Math.sin(yaw1 - yaw2), Math.cos(yaw1 - yaw2)));
|
||||
const isParallel = angleDiff < 0.05 || Math.abs(angleDiff - Math.PI) < 0.05;
|
||||
|
||||
if (isParallel) {
|
||||
const dirX = Math.cos(yaw1);
|
||||
const dirZ = -Math.sin(yaw1);
|
||||
|
||||
const dx = p2.position[0] - p1.position[0];
|
||||
const dy = p2.position[1] - p1.position[1];
|
||||
const dz = p2.position[2] - p1.position[2];
|
||||
|
||||
// Project distance along pipe vector and find lateral offset
|
||||
const proj = dx * dirX + dz * dirZ;
|
||||
const latX = dx - proj * dirX;
|
||||
const latZ = dz - proj * dirZ;
|
||||
const latDist = Math.hypot(latX, dy, latZ);
|
||||
|
||||
if (latDist < 0.12) {
|
||||
const len1 = p1.params.length ?? 2;
|
||||
const len2 = p2.params.length ?? 2;
|
||||
const maxAllowedDist = (len1 + len2) / 2 - 0.05;
|
||||
|
||||
if (dist < maxAllowedDist - 0.2) {
|
||||
const key = [p1.id, p2.id].sort().join(':');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
findings.push({
|
||||
id: `part-overlap:${key}`,
|
||||
severity: 'warning',
|
||||
title: 'Collinear pipes overlapping',
|
||||
detail: `${partName(p1)} and ${partName(p2)} are collinear but overlapping by ${(maxAllowedDist - dist).toFixed(1)} ft, causing them to morph together.`,
|
||||
partIds: [p1.id, p2.id],
|
||||
fix: 'Adjust their positions or delete one',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
Reference in New Issue
Block a user