Implement derived grow check rules, auto-fix behaviors, and import hardening

This commit is contained in:
drjones
2026-06-13 23:51:43 -07:00
parent 84e2c6416d
commit 6f3c30f44d
5 changed files with 278 additions and 7 deletions

View File

@@ -348,6 +348,21 @@ function BuilderApp() {
if (ids.length >= 2) { if (ids.length >= 2) {
s.removeParts([ids[1]]); s.removeParts([ids[1]]);
} }
} else if (finding.id.startsWith('nutrient-depletion:')) {
const resId = finding.partIds[0];
if (resId) {
const p = s.parts[resId];
if (p) {
s.updatePart(resId, {
params: {
...p.params,
width: Math.min(5, (p.params.width ?? 2) * 1.5),
depth: Math.min(4, (p.params.depth ?? 1.4) * 1.5),
height: Math.min(3, (p.params.height ?? 1) * 1.2),
}
});
}
}
} else if (finding.id.startsWith('orphan:')) { } else if (finding.id.startsWith('orphan:')) {
s.removeParts(finding.partIds); s.removeParts(finding.partIds);
} }

View File

@@ -1016,8 +1016,15 @@ export const useBuilder = create<BuilderStore>((set, get) => ({
const s = get(); const s = get();
if (!pasted.length) return []; if (!pasted.length) return [];
// Rigorously validate and sanitize each pasted part const sanitized: PlacedPart[] = [];
const sanitized = pasted.map((p) => sanitizePart(p)); for (const p of pasted) {
try {
sanitized.push(sanitizePart(p));
} catch (err) {
console.warn('Skipping corrupted part during paste:', p, err);
}
}
if (!sanitized.length) return [];
s.pushHistory('paste'); s.pushHistory('paste');
const offset: Vec3 = [1, 0, 1]; const offset: Vec3 = [1, 0, 1];

View File

@@ -104,7 +104,7 @@ export function parseProjectFile(raw: unknown): ProjectFile {
try { try {
validatedParts.push(sanitizePart(p)); validatedParts.push(sanitizePart(p));
} catch (err) { } catch (err) {
throw new Error(`Project file validation failed: ${(err as Error).message}`); console.warn('Skipping corrupted part during import:', p, err);
} }
} }

View File

@@ -68,10 +68,35 @@ function FindingCard({
onApplyFix?: (finding: Finding) => void; onApplyFix?: (finding: Finding) => void;
}) { }) {
const meta = SEVERITY_META[finding.severity]; const meta = SEVERITY_META[finding.severity];
const canApplyFix = const fixablePrefixes = [
finding.id.startsWith('dead-leg:') || 'dead-leg:',
finding.id.startsWith('diameter-mismatch:') || 'diameter-mismatch:',
finding.id.startsWith('pump-dead:'); 'pump-dead:',
'pump-head:',
'unsupported-span:',
'drain-capacity:',
'res-temp-high:',
'res-ph-low:',
'res-ph-high:',
'res-ec-low:',
'res-ec-high:',
'room-co2-open:',
'room-co2-missing:',
'room-co2-starved:',
'room-sealed-exhaust:',
'room-vent-low:',
'room-light-low:',
'room-light-high:',
'room-dli-low:',
'room-dli-high:',
'room-vpd-low:',
'room-vpd-high:',
'closed-valve:',
'part-overlap:',
'orphan:',
'nutrient-depletion:',
];
const canApplyFix = fixablePrefixes.some((prefix) => finding.id.startsWith(prefix));
return ( return (
<div className={`rounded-lg border px-3 py-2.5 ${meta.card}`}> <div className={`rounded-lg border px-3 py-2.5 ${meta.card}`}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">

View File

@@ -703,6 +703,21 @@ export const ALL_RULES: ValidationRule[] = [
description: 'Parts that are overlapping or morphing into one another.', description: 'Parts that are overlapping or morphing into one another.',
run: partOverlaps, 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[] { function reservoirWaterChemistry(ctx: RuleContext): Finding[] {
@@ -883,3 +898,212 @@ function partOverlaps(ctx: RuleContext): Finding[] {
} }
return findings; 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;
}