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([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([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(); 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([`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(); 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; }