|
|
|
|
@@ -703,6 +703,21 @@ export const ALL_RULES: ValidationRule[] = [
|
|
|
|
|
description: 'Parts that are overlapping or morphing into one another.',
|
|
|
|
|
run: partOverlaps,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'structural-floor-load',
|
|
|
|
|
description: 'Total equipment and water load per square foot of room footprint.',
|
|
|
|
|
run: structuralFloorLoad,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'nutrient-depletion',
|
|
|
|
|
description: 'Reservoir buffer volume capacity relative to plant count.',
|
|
|
|
|
run: nutrientDepletion,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'manifold-imbalance',
|
|
|
|
|
description: 'Uniformity of delivered flow rates across multiple emitters.',
|
|
|
|
|
run: manifoldImbalance,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function reservoirWaterChemistry(ctx: RuleContext): Finding[] {
|
|
|
|
|
@@ -883,3 +898,212 @@ function partOverlaps(ctx: RuleContext): Finding[] {
|
|
|
|
|
}
|
|
|
|
|
return findings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function structuralFloorLoad(ctx: RuleContext): Finding[] {
|
|
|
|
|
const findings: Finding[] = [];
|
|
|
|
|
const rooms = new Set<string>();
|
|
|
|
|
for (const p of ctx.parts) {
|
|
|
|
|
if (p.roomId) rooms.add(p.roomId);
|
|
|
|
|
}
|
|
|
|
|
if (rooms.size === 0) rooms.add('room-1');
|
|
|
|
|
|
|
|
|
|
for (const roomId of rooms) {
|
|
|
|
|
const roomParts = ctx.parts.filter((p) => (p.roomId || 'room-1') === roomId);
|
|
|
|
|
if (roomParts.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
let totalWeight = 0;
|
|
|
|
|
let minX = Infinity;
|
|
|
|
|
let maxX = -Infinity;
|
|
|
|
|
let minZ = Infinity;
|
|
|
|
|
let maxZ = -Infinity;
|
|
|
|
|
|
|
|
|
|
for (const p of roomParts) {
|
|
|
|
|
if (p.type !== 'growTent') {
|
|
|
|
|
minX = Math.min(minX, p.position[0]);
|
|
|
|
|
maxX = Math.max(maxX, p.position[0]);
|
|
|
|
|
minZ = Math.min(minZ, p.position[2]);
|
|
|
|
|
maxZ = Math.max(maxZ, p.position[2]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let dryWeight = 1;
|
|
|
|
|
if (p.type === 'reservoir') dryWeight = 15;
|
|
|
|
|
else if (p.type === 'tray') dryWeight = 10;
|
|
|
|
|
else if (p.type === 'pump') dryWeight = 5;
|
|
|
|
|
else if (p.type === 'waterChiller') dryWeight = 35;
|
|
|
|
|
else if (p.type === 'growLight') dryWeight = 12;
|
|
|
|
|
else if (p.type === 'inlineFan') dryWeight = 8;
|
|
|
|
|
else if (p.type === 'circulationFan') dryWeight = 3;
|
|
|
|
|
else if (p.type === 'co2Tank') dryWeight = 30;
|
|
|
|
|
else if (p.type === 'tower') dryWeight = 12;
|
|
|
|
|
|
|
|
|
|
let waterWeight = 0;
|
|
|
|
|
if (p.type === 'reservoir') {
|
|
|
|
|
const w = p.params.width ?? 2;
|
|
|
|
|
const d = p.params.depth ?? 1.4;
|
|
|
|
|
const h = p.params.height ?? 1;
|
|
|
|
|
const gallons = w * d * h * 7.48;
|
|
|
|
|
waterWeight = gallons * 8.34;
|
|
|
|
|
} else if (p.type === 'tray') {
|
|
|
|
|
const l = p.params.length ?? 3;
|
|
|
|
|
const w = p.params.width ?? 1.2;
|
|
|
|
|
const gallons = l * w * 0.1 * 7.48;
|
|
|
|
|
waterWeight = gallons * 8.34;
|
|
|
|
|
} else if (p.type === 'pipe') {
|
|
|
|
|
const d = p.params.diameter ?? 1.0;
|
|
|
|
|
const len = p.params.length ?? 2;
|
|
|
|
|
const r = 0.05 + d * 0.055;
|
|
|
|
|
const volFt3 = Math.PI * r * r * len;
|
|
|
|
|
waterWeight = volFt3 * 7.48 * 8.34;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalWeight += dryWeight + waterWeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (totalWeight <= 20) continue;
|
|
|
|
|
|
|
|
|
|
const dx = maxX - minX;
|
|
|
|
|
const dz = maxZ - minZ;
|
|
|
|
|
const footprintArea = Math.max(4, (dx > 0 ? dx : 2) * (dz > 0 ? dz : 2));
|
|
|
|
|
const floorPressure = totalWeight / footprintArea;
|
|
|
|
|
|
|
|
|
|
const tent = roomParts.find((p) => p.type === 'growTent');
|
|
|
|
|
const roomName = tent ? (tent.label || 'Grow Tent') : `Room (${roomId === 'room-1' ? 'Room 1' : roomId})`;
|
|
|
|
|
|
|
|
|
|
if (floorPressure > 60) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `floor-weight:${roomId}`,
|
|
|
|
|
severity: 'error',
|
|
|
|
|
title: 'Critical floor weight loading',
|
|
|
|
|
detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a load of ${floorPressure.toFixed(1)} lbs/sq ft. This exceeds standard residential ceiling/loft safety limits (40 lbs/sq ft).`,
|
|
|
|
|
partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id),
|
|
|
|
|
fix: 'Reduce reservoir sizes, split the load across multiple rooms, or move the layout to a ground/concrete slab floor.',
|
|
|
|
|
});
|
|
|
|
|
} else if (floorPressure > 40) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `floor-weight:${roomId}`,
|
|
|
|
|
severity: 'warning',
|
|
|
|
|
title: 'High floor weight loading',
|
|
|
|
|
detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a load of ${floorPressure.toFixed(1)} lbs/sq ft. This is near the standard 40 lbs/sq ft residential loft load rating limit.`,
|
|
|
|
|
partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id),
|
|
|
|
|
fix: 'Ensure your floor has structural joist support directly under the heavy reservoirs, or reduce reservoir water levels.',
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `floor-weight:${roomId}`,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
title: 'Floor weight loading normal',
|
|
|
|
|
detail: `${roomName} total weight is ${totalWeight.toFixed(0)} lbs over ${footprintArea.toFixed(1)} sq ft, resulting in a safe load of ${floorPressure.toFixed(1)} lbs/sq ft.`,
|
|
|
|
|
partIds: roomParts.filter(p => p.type === 'reservoir' || p.type === 'tray').map(p => p.id),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return findings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nutrientDepletion(ctx: RuleContext): Finding[] {
|
|
|
|
|
const findings: Finding[] = [];
|
|
|
|
|
const reservoirs = ctx.parts.filter((p) => p.type === 'reservoir');
|
|
|
|
|
|
|
|
|
|
for (const res of reservoirs) {
|
|
|
|
|
const w = res.params.width ?? 2;
|
|
|
|
|
const d = res.params.depth ?? 1.4;
|
|
|
|
|
const h = res.params.height ?? 1;
|
|
|
|
|
const gallons = w * d * h * 7.48;
|
|
|
|
|
|
|
|
|
|
const netPots = ctx.parts.filter(
|
|
|
|
|
(p) => (p.roomId || 'room-1') === (res.roomId || 'room-1') && (p.type === 'netPot' || p.type === 'tower')
|
|
|
|
|
);
|
|
|
|
|
const plantCount = netPots.length;
|
|
|
|
|
if (plantCount === 0) continue;
|
|
|
|
|
|
|
|
|
|
const gallonsPerPlant = gallons / plantCount;
|
|
|
|
|
const label = res.label || `Reservoir #${res.id}`;
|
|
|
|
|
|
|
|
|
|
if (gallonsPerPlant < 0.4) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `nutrient-depletion:${res.id}`,
|
|
|
|
|
severity: 'error',
|
|
|
|
|
title: 'Critical reservoir size (EC/pH drift)',
|
|
|
|
|
detail: `${label} has only ${gallonsPerPlant.toFixed(2)} gal per plant site (Total: ${gallons.toFixed(0)} gal for ${plantCount} sites). Roots will suffer from severe nutrient depletion, water stress, and rapid pH drift.`,
|
|
|
|
|
partIds: [res.id],
|
|
|
|
|
fix: 'Increase reservoir size or reduce the number of plant sites in this room to achieve at least 1.0 gal/plant.',
|
|
|
|
|
});
|
|
|
|
|
} else if (gallonsPerPlant < 0.8) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `nutrient-depletion:${res.id}`,
|
|
|
|
|
severity: 'warning',
|
|
|
|
|
title: 'High pH/EC drift risk',
|
|
|
|
|
detail: `${label} has ${gallonsPerPlant.toFixed(2)} gal per plant site. Hydroponic setups require at least 1.0 gal/plant to maintain chemical buffer stability. Expected daily pH/EC drift is high.`,
|
|
|
|
|
partIds: [res.id],
|
|
|
|
|
fix: 'Increase reservoir capacity or add a secondary top-off tank.',
|
|
|
|
|
});
|
|
|
|
|
} else if (gallonsPerPlant < 1.5) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `nutrient-depletion:${res.id}`,
|
|
|
|
|
severity: 'info',
|
|
|
|
|
title: 'pH/EC drift warning',
|
|
|
|
|
detail: `${label} has ${gallonsPerPlant.toFixed(2)} gal per plant site. Monitor pH and nutrient strength (EC) daily as buffer levels are moderate.`,
|
|
|
|
|
partIds: [res.id],
|
|
|
|
|
fix: 'Recommended target is 2.0 gallons per plant site for hands-off stability.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return findings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function manifoldImbalance(ctx: RuleContext): Finding[] {
|
|
|
|
|
const findings: Finding[] = [];
|
|
|
|
|
if (!ctx.sim) return findings;
|
|
|
|
|
|
|
|
|
|
const rooms = new Set<string>();
|
|
|
|
|
for (const p of ctx.parts) {
|
|
|
|
|
if (p.roomId) rooms.add(p.roomId);
|
|
|
|
|
}
|
|
|
|
|
if (rooms.size === 0) rooms.add('room-1');
|
|
|
|
|
|
|
|
|
|
for (const roomId of rooms) {
|
|
|
|
|
const roomEmitters = ctx.parts.filter(
|
|
|
|
|
(p) => (p.roomId || 'room-1') === roomId && p.type === 'emitter'
|
|
|
|
|
);
|
|
|
|
|
if (roomEmitters.length < 2) continue;
|
|
|
|
|
|
|
|
|
|
const flows = roomEmitters
|
|
|
|
|
.map((e) => ctx.sim!.flows[e.id] ?? 0)
|
|
|
|
|
.filter((q) => q > 0);
|
|
|
|
|
|
|
|
|
|
if (flows.length < 2) continue;
|
|
|
|
|
|
|
|
|
|
const maxFlow = Math.max(...flows);
|
|
|
|
|
const minFlow = Math.min(...flows);
|
|
|
|
|
|
|
|
|
|
const sum = flows.reduce((a, b) => a + b, 0);
|
|
|
|
|
const avg = sum / flows.length;
|
|
|
|
|
if (avg <= 0.01) continue;
|
|
|
|
|
|
|
|
|
|
const variancePct = ((maxFlow - minFlow) / avg) * 100;
|
|
|
|
|
|
|
|
|
|
if (variancePct > 50) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `manifold-imbalance:${roomId}`,
|
|
|
|
|
severity: 'error',
|
|
|
|
|
title: 'Critical flow rate imbalance',
|
|
|
|
|
detail: `Manifold has a critical flow variance of ${variancePct.toFixed(0)}% between drip emitters (max: ${maxFlow.toFixed(1)} GPH, min: ${minFlow.toFixed(1)} GPH). Plants will experience severe uneven watering.`,
|
|
|
|
|
partIds: roomEmitters.map((e) => e.id),
|
|
|
|
|
fix: 'Use pressure-compensating emitters, balanced loops, or increase main pipe size to distribute pressure evenly.',
|
|
|
|
|
});
|
|
|
|
|
} else if (variancePct > 25) {
|
|
|
|
|
findings.push({
|
|
|
|
|
id: `manifold-imbalance:${roomId}`,
|
|
|
|
|
severity: 'warning',
|
|
|
|
|
title: 'Uneven emitter flow distribution',
|
|
|
|
|
detail: `Manifold has a flow variance of ${variancePct.toFixed(0)}% between drip emitters (max: ${maxFlow.toFixed(1)} GPH, min: ${minFlow.toFixed(1)} GPH). Some plants will receive more water than others.`,
|
|
|
|
|
partIds: roomEmitters.map((e) => e.id),
|
|
|
|
|
fix: 'Ensure the main manifold is looped or adjust pipe lengths to equalize pressure drop.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return findings;
|
|
|
|
|
}
|
|
|
|
|
|