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:
2026-01-20 20:59:04 -05:00
parent 4749944a48
commit 0c252e3a65
2 changed files with 242 additions and 1 deletions

View File

@@ -5280,3 +5280,155 @@ async def get_nx_expressions(study_id: str):
raise
except Exception as 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)}")

View File

@@ -21,6 +21,8 @@ import {
Settings2,
GitBranch,
File,
Database,
CheckCircle2,
} from 'lucide-react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
@@ -101,6 +103,22 @@ interface IntrospectionResult {
units?: Record<string, unknown>;
linked_parts?: Record<string, unknown>;
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
@@ -125,7 +143,7 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['expressions', 'extractors'])
new Set(['expressions', 'extractors', 'file_deps', 'fea_results'])
);
const [searchTerm, setSearchTerm] = useState('');
@@ -783,6 +801,77 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
</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 */}
{(result.extractors_available?.length ?? 0) > 0 && (
<div className="border border-dark-700 rounded-lg overflow-hidden">