feat: Add sub-part introspection and existing FEA results UI
Backend:
- GET /nx/parts - List all .prt files in model directory
- GET /nx/introspect/{part_name} - Introspect a specific part file
(e.g., M1_Blank.prt instead of just the assembly)
- Each part gets its own cache file (_introspection_{stem}.json)
Frontend IntrospectionPanel:
- Add 'FEA Results' section showing existing OP2/F06 sources
- Green checkmark when results exist, shows recommended source
- Expand file_deps and fea_results sections by default
- Add CheckCircle2 and Database icons
This allows introspecting component parts that contain the actual
design variable expressions (e.g., M1_Blank has 56 expressions
while the assembly ASSY_M1 only has 5).
This commit is contained in:
@@ -5280,3 +5280,155 @@ async def get_nx_expressions(study_id: str):
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get expressions: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get expressions: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/nx/introspect/{part_name}")
|
||||||
|
async def introspect_specific_part(study_id: str, part_name: str, force: bool = False):
|
||||||
|
"""
|
||||||
|
Introspect a specific .prt file in the model directory.
|
||||||
|
|
||||||
|
Use this to get expressions from component parts (e.g., M1_Blank.prt)
|
||||||
|
rather than just the main assembly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
part_name: Name of the part file (e.g., "M1_Blank.prt" or "M1_Blank")
|
||||||
|
force: Force re-introspection even if cached
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with introspection results for the specific part
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = resolve_study_path(study_id)
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Find model directory
|
||||||
|
model_dir = None
|
||||||
|
for possible_dir in [
|
||||||
|
study_dir / "1_setup" / "model",
|
||||||
|
study_dir / "1_model",
|
||||||
|
study_dir / "model",
|
||||||
|
study_dir / "1_setup",
|
||||||
|
]:
|
||||||
|
if possible_dir.exists():
|
||||||
|
model_dir = possible_dir
|
||||||
|
break
|
||||||
|
|
||||||
|
if model_dir is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
||||||
|
|
||||||
|
# Normalize part name
|
||||||
|
if not part_name.lower().endswith(".prt"):
|
||||||
|
part_name = part_name + ".prt"
|
||||||
|
|
||||||
|
# Find the part file (case-insensitive)
|
||||||
|
prt_file = None
|
||||||
|
for f in model_dir.glob("*.prt"):
|
||||||
|
if f.name.lower() == part_name.lower():
|
||||||
|
prt_file = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if prt_file is None:
|
||||||
|
# List available parts for helpful error
|
||||||
|
available = [f.name for f in model_dir.glob("*.prt")]
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Part '{part_name}' not found. Available: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check cache
|
||||||
|
cache_file = model_dir / f"_introspection_{prt_file.stem}.json"
|
||||||
|
if cache_file.exists() and not force:
|
||||||
|
try:
|
||||||
|
with open(cache_file, "r") as f:
|
||||||
|
cached = json.load(f)
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"part_name": prt_file.name,
|
||||||
|
"cached": True,
|
||||||
|
"introspection": cached,
|
||||||
|
}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Run introspection
|
||||||
|
try:
|
||||||
|
from optimization_engine.extractors.introspect_part import introspect_part
|
||||||
|
|
||||||
|
result = introspect_part(str(prt_file), str(model_dir), verbose=False)
|
||||||
|
|
||||||
|
# Cache results
|
||||||
|
with open(cache_file, "w") as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"part_name": prt_file.name,
|
||||||
|
"cached": False,
|
||||||
|
"introspection": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="introspect_part module not available")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Introspection failed: {str(e)}")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to introspect part: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/nx/parts")
|
||||||
|
async def list_model_parts(study_id: str):
|
||||||
|
"""
|
||||||
|
List all .prt files in the study's model directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of available parts that can be introspected
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = resolve_study_path(study_id)
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Find model directory
|
||||||
|
model_dir = None
|
||||||
|
for possible_dir in [
|
||||||
|
study_dir / "1_setup" / "model",
|
||||||
|
study_dir / "1_model",
|
||||||
|
study_dir / "model",
|
||||||
|
study_dir / "1_setup",
|
||||||
|
]:
|
||||||
|
if possible_dir.exists():
|
||||||
|
model_dir = possible_dir
|
||||||
|
break
|
||||||
|
|
||||||
|
if model_dir is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
||||||
|
|
||||||
|
# Collect all part files
|
||||||
|
parts = []
|
||||||
|
for f in sorted(model_dir.glob("*.prt")):
|
||||||
|
is_idealized = "_i.prt" in f.name.lower()
|
||||||
|
parts.append(
|
||||||
|
{
|
||||||
|
"name": f.name,
|
||||||
|
"stem": f.stem,
|
||||||
|
"is_idealized": is_idealized,
|
||||||
|
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||||
|
"has_cache": (model_dir / f"_introspection_{f.stem}.json").exists(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"study_id": study_id,
|
||||||
|
"model_dir": str(model_dir),
|
||||||
|
"parts": parts,
|
||||||
|
"count": len(parts),
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list parts: {str(e)}")
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
File,
|
File,
|
||||||
|
Database,
|
||||||
|
CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
||||||
|
|
||||||
@@ -101,6 +103,22 @@ interface IntrospectionResult {
|
|||||||
units?: Record<string, unknown>;
|
units?: Record<string, unknown>;
|
||||||
linked_parts?: Record<string, unknown>;
|
linked_parts?: Record<string, unknown>;
|
||||||
file_dependencies?: FileDependencies;
|
file_dependencies?: FileDependencies;
|
||||||
|
existing_fea_results?: {
|
||||||
|
has_results: boolean;
|
||||||
|
sources: Array<{
|
||||||
|
location: string;
|
||||||
|
path: string;
|
||||||
|
op2: string[];
|
||||||
|
f06: string[];
|
||||||
|
bdf: string[];
|
||||||
|
timestamp?: number;
|
||||||
|
}>;
|
||||||
|
recommended?: {
|
||||||
|
location: string;
|
||||||
|
path: string;
|
||||||
|
op2: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline run result interface
|
// Baseline run result interface
|
||||||
@@ -125,7 +143,7 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set(['expressions', 'extractors'])
|
new Set(['expressions', 'extractors', 'file_deps', 'fea_results'])
|
||||||
);
|
);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
@@ -783,6 +801,77 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Existing FEA Results Section */}
|
||||||
|
{result.existing_fea_results && (
|
||||||
|
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('fea_results')}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{result.existing_fea_results.has_results ? (
|
||||||
|
<CheckCircle2 size={14} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Database size={14} className="text-dark-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
FEA Results {result.existing_fea_results.has_results ?
|
||||||
|
`(${result.existing_fea_results.sources?.length || 0} sources)` :
|
||||||
|
'(none)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.has('fea_results') ? (
|
||||||
|
<ChevronDown size={14} className="text-dark-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} className="text-dark-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.has('fea_results') && (
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{result.existing_fea_results.has_results ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-green-400 mb-2">
|
||||||
|
Existing results found - no solve needed for extraction
|
||||||
|
</p>
|
||||||
|
{result.existing_fea_results.sources?.map((source, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-2 rounded text-xs ${
|
||||||
|
source.location === result.existing_fea_results?.recommended?.location
|
||||||
|
? 'bg-green-500/10 border border-green-500/30'
|
||||||
|
: 'bg-dark-850'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-white font-medium">{source.location}</span>
|
||||||
|
{source.location === result.existing_fea_results?.recommended?.location && (
|
||||||
|
<span className="text-[10px] text-green-400 bg-green-500/20 px-1 rounded">
|
||||||
|
recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-dark-400 space-y-0.5">
|
||||||
|
{source.op2?.length > 0 && (
|
||||||
|
<p>OP2: {source.op2.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
{source.f06?.length > 0 && (
|
||||||
|
<p>F06: {source.f06.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-dark-500 text-center py-2">
|
||||||
|
No FEA results found. Run baseline to generate results.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Extractors Section - only show if available */}
|
{/* Extractors Section - only show if available */}
|
||||||
{(result.extractors_available?.length ?? 0) > 0 && (
|
{(result.extractors_available?.length ?? 0) > 0 && (
|
||||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user