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
|
||||
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)}")
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user