Implement derived grow check rules, auto-fix behaviors, and import hardening
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user