886 lines
34 KiB
TypeScript
886 lines
34 KiB
TypeScript
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;
|
|
}
|