Skip to content

Project Management

The EMProject class is the central entry point for managing simulations, geometry, and results. It orchestrates the full workflow: geometry creation, meshing, solving, reduction, and persistence.

EMProject

cavsim3d.core.em_project.EMProject

Central class for managing electromagnetic simulation projects.

Responsibility: - Manage the project directory structure. - Orchestrate saving and loading of geometry, mesh, and solvers. - Provide a unified entry point for simulation.

Source code in cavsim3d/core/em_project.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
class EMProject:
    """
    Central class for managing electromagnetic simulation projects.

    Responsibility:
    - Manage the project directory structure.
    - Orchestrate saving and loading of geometry, mesh, and solvers.
    - Provide a unified entry point for simulation.
    """

    def create_assembly(self, main_axis: str = 'Z') -> 'Assembly':
        """
        Create a new multi-component assembly for this project.

        This sets the project's geometry to an empty Assembly and returns it.
        You can then add components to the assembly using assembly.add().

        Parameters
        ----------
        main_axis : str
            Primary axis for concatenation ('X', 'Y', or 'Z')

        Returns
        -------
        Assembly
            The new assembly instance
        """
        self.geometry = Assembly(main_axis=main_axis)
        self.save()
        return self.geometry

    def __init__(
        self,
        name: str,
        base_dir: Optional[Union[str, Path]] = None,
        geometry: Optional[BaseGeometry] = None,
        bc: str = None,
        overwrite: bool = False,
    ):
        self.name = name
        # Use current directory if base_dir is not provided
        self.base_dir = Path(base_dir) if base_dir else Path.cwd()
        self.project_path = self.base_dir / self.name

        # Overwrite protection: if overwrite=True, delete existing project folder
        if overwrite and self.project_path.exists():
            pr.info(f"Project '{self.name}' already exists and overwrite=True. Deleting old project...")
            shutil.rmtree(self.project_path)

        self._geometry = geometry
        self.bc = bc
        self._mesh: Optional[Mesh] = None
        self._fds: Optional[FrequencyDomainSolver] = None
        self._order = 3
        self._n_port_modes = 1
        self._loading = False  # Guard flag to prevent save() during _initial_load()

        # Automatic Loading or Creation
        if self.project_path.exists():
            pr.milestone(f"Project '{self.name}' exists. Loading...")
            self._initial_load()
            pr.milestone(f"Project '{self.name}' loaded.")
        else:
            pr.milestone(f"Creating new project '{self.name}' at {self.project_path}")
            self.project_path.mkdir(parents=True, exist_ok=True)
            self.geometry_path.mkdir(parents=True, exist_ok=True)
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            self.fds_path.mkdir(parents=True, exist_ok=True)
            # Automatic save on creation if geometry is provided
            if self.geometry:
                self.save()

        # Show welcome banner in Jupyter
        self._show_welcome_banner()

    def _show_welcome_banner(self):
        """Display the cavsim3d logo in Jupyter notebooks."""
        try:
            from IPython import get_ipython
            if get_ipython() is None:
                return  # Not in IPython/Jupyter
            from IPython.display import display, SVG, HTML
            logo_path = os.path.join(
                os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
                "docs", "assets", "cavsim3d_logo_square.svg"
            )
            if os.path.exists(logo_path):
                display(HTML(f"""
                <div style="display: flex; align-items: center; gap: 12px;">
                    <img src="data:image/svg+xml;base64,{__import__('base64').b64encode(open(logo_path, 'rb').read()).decode()}" style="height: 40px;">
                    <span style="font-size: 16px; font-weight: bold; color: #e66433;">CAVSIM-3D</span>
                    <span style="font-size: 13px; color: #888;">v0.1.0 &mdash; {self.name}</span>
                </div>
                """))
        except ImportError:
            pass  # Not in Jupyter, skip silently

    def _initial_load(self):
        """Internal helper for automatic loading during instantiation."""
        metadata_file = self.project_path / "project.json"
        if not metadata_file.exists():
            return

        self._loading = True  # Prevent save() from being triggered during load

        with open(metadata_file, "r") as f:
            metadata = json.load(f)

        self.bc = metadata.get("bc", self.bc)
        self._order = metadata.get("order", self._order)
        self._n_port_modes = metadata.get("n_port_modes", self._n_port_modes)

        # 1. Load Geometry FIRST
        has_geo = metadata.get("has_geometry", False)
        # Self-healing: check if geometry folder has contents even if flag is false
        if not has_geo and self.geometry_path.exists() and any(self.geometry_path.iterdir()):
            has_geo = True

        if has_geo:
            try:
                self.geometry = BaseGeometry.load_geometry(self.project_path)
            except Exception as e:
                pr.warning(f"Could not load geometry: {e}")

        # 2. Load Mesh SECOND (needed for FDS port modes)
        has_mesh = metadata.get("has_mesh", False)
        # Self-healing: check if mesh folder has contents even if flag is false
        if not has_mesh and self.mesh_path.exists() and (self.mesh_path / "mesh.pkl").exists():
            has_mesh = True

        if has_mesh:
            pm = ProjectManager(self.base_dir)
            self.mesh = pm.load_ngs_mesh(self.mesh_path)

        # 3. Load Solver (FDS) LAST - needs mesh for port mode reconstruction
        if metadata.get("has_fds"):

            # Pass mesh to load method so port modes can be reconstructed
            self._fds = FrequencyDomainSolver.load_from_path(
                self.fds_path,
                geometry=self.geometry,
                mesh=self.mesh,  # Pass mesh here
                order=self._order,
                bc=self.bc
            )

            if self._fds:
                self._fds._project_path = self.project_path
                # Only sync mesh if the FDS doesn't have one already (load_from_path handles it)
                if self.mesh and self._fds.mesh is None:
                    self._fds.mesh = self.mesh
                    # Restore FES
                    pm = ProjectManager(self.base_dir)
                    fes = pm.load_ngs_fes(self.fds_path)
                    if fes:
                        self._fds._fes_global = fes

        self._loading = False  # Re-enable save()

    @property
    def geo(self) -> Optional[BaseGeometry]:
        """Shortcut for geometry."""
        return self.geometry

    @property
    def fds(self) -> FrequencyDomainSolver:
        """Lazy initialization of the FrequencyDomainSolver."""
        if self._fds is None:
            if self.geometry is None:
                raise RuntimeError("Cannot initialize solver without geometry.")
            from cavsim3d.solvers.frequency_domain import FrequencyDomainSolver
            self._fds = FrequencyDomainSolver(
                geometry=self.geometry,
                order=self.order,
                bc=self.bc
            )
            self._fds._project_path = self.project_path
            self._fds._project_name = self.name
            self._fds._project_ref = self
        return self._fds

    @fds.setter
    def fds(self, value: Optional[FrequencyDomainSolver]):
        self._fds = value
        if value:
            value._project_path = self.project_path
            value._project_name = self.name
            value._project_ref = self
            value.order = self._order
            value.n_port_modes = self._n_port_modes

    def import_geometry(self, filepath: Union[str, Path], force: bool = False, **kwargs) -> 'OCCImporter':
        """Import geometry from a file into the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Importing new geometry will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting geometry import.")
                return self.geometry

            self.invalidate_mesh()

        self.geometry = self.create_importer(filepath, **kwargs)
        self.save()  # Auto-save after successful import
        return self.geometry

    def create_importer(self, filepath: Union[str, Path], **kwargs) -> 'OCCImporter':
        """Create an OCCImporter for a CAD file (without necessarily setting it as the project geometry)."""
        return OCCImporter(str(filepath), **kwargs)

    def create_primitive(self, primitive_type: str, force: bool = False, **kwargs) -> BaseGeometry:
        """Create a primitive geometry and associate it with the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Geometry change will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting primitive creation.")
                return self.geometry

            self.invalidate_mesh()

        # Map string names to classes
        mapping = {
            'rectangular_waveguide': primitives.RectangularWaveguide,
            'circular_waveguide': primitives.CircularWaveguide,
            'rwg': primitives.RectangularWaveguide,
            'cwg': primitives.CircularWaveguide,
        }

        cls = mapping.get(primitive_type.lower())
        if not cls:
            raise ValueError(f"Unknown primitive type: {primitive_type}")

        self.geometry = cls(**kwargs)
        self.save()  # Auto-save
        return self.geometry

    def generate_mesh(self, force: bool = False, **kwargs) -> Mesh:
        """
        Generate mesh from current geometry.

        Automatically invalidates existing simulation results if the mesh changes.
        """
        if self.geometry is None:
            raise RuntimeError("Cannot generate mesh without geometry.")

        if self.has_results() and not force:
            if not get_user_confirmation(
                "\nWARNING: Re-generating the mesh will invalidate existing simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting mesh generation.")
                return self.mesh

            self.invalidate_results()

        self.mesh = self.geometry.generate_mesh(**kwargs)
        self.save()
        return self.mesh

    def has_mesh(self) -> bool:
        """Check if mesh exists (either in memory or on disk)."""
        if self.mesh is not None:
            return True
        return (self.mesh_path / "mesh.pkl").exists()

    def has_results(self) -> bool:
        """Check if any simulation results exist (either in memory or on disk)."""
        # Check in memory
        if self._fds and (getattr(self._fds, '_fom_cache', None) or getattr(self._fds, '_resonant_mode_cache', None)):
            return True

        # Check on disk (config.json marks a valid simulation save)
        return (self.fds_path / "config.json").exists()

    def invalidate_mesh(self) -> None:
        """Invalidate the mesh and all downstream results (fom, rom, etc.)."""
        pr.info(f"Invalidating mesh for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directory)
        if self.mesh_path.exists():
            for item in self.mesh_path.iterdir():
                if item.is_dir():
                    shutil.rmtree(item)
                else:
                    item.unlink()

        # 2. In-Memory Reset
        self.mesh = None
        if self.geometry:
            self.geometry.mesh = None # Sync with geometry object

        # 3. Propagate Downstream
        self.invalidate_results()

    def invalidate_results(self) -> None:
        """Invalidate all simulation results (fom, rom, concat, etc.)."""
        pr.info(f"Invalidating simulation results for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directories)
        for path in [self.fds_path, self.fom_path, self.foms_path, self.eigenmode_path]:
            if path.exists():
                for item in path.iterdir():
                    if item.is_dir():
                        shutil.rmtree(item)
                    else:
                        item.unlink()

        # 2. In-Memory Reset
        if self._fds:
            # We don't delete self._fds object, but we clear its entire state.
            # Use full_reset to ensure matrices, FES, and flags are cleared.
            self._fds.full_reset()
            self._fds.mesh = self.mesh # Ensure solver still has access to new mesh if it exists

    @property
    def geometry(self) -> Optional[BaseGeometry]:
        """Current project geometry."""
        return self._geometry

    @geometry.setter
    def geometry(self, value: Optional[BaseGeometry]):
        self._geometry = value
        # Sync solver with new geometry object
        if self._fds:
            self._fds.geometry = value

    @property
    def mesh(self) -> Optional[Mesh]:
        """Project mesh (NGSolve Mesh object)."""
        return self._mesh

    @mesh.setter
    def mesh(self, value: Optional[Mesh]):
        self._mesh = value
        if self._fds:
            self._fds.mesh = value

        # Auto-save mesh to disk if it exists (but NOT during _initial_load)
        if value is not None and not getattr(self, '_loading', False):
            self.save()

    @property
    def order(self) -> int:
        return self._order

    @order.setter
    def order(self, value: int):
        self._order = value
        if self._fds:
            self._fds.order = value

    @property
    def n_port_modes(self) -> int:
        return self._n_port_modes

    @n_port_modes.setter
    def n_port_modes(self, value: int):
        self._n_port_modes = value
        if self._fds:
            self._fds.n_port_modes = value

    @property
    def mesh_path(self) -> Path:
        return self.project_path / "mesh"

    @property
    def fds_path(self) -> Path:
        return self.project_path / "fds"

    @property
    def geometry_path(self) -> Path:
        return self.project_path / "geometry"

    @property
    def fom_path(self) -> Path:
        return self.project_path / "fom"

    @property
    def foms_path(self) -> Path:
        return self.project_path / "foms"

    @property
    def eigenmode_path(self) -> Path:
        return self.project_path / "eigenmode"

    def save(self):
        """Save the entire project with the new folder structure."""
        pr.info(f"Saving project to {self.project_path}")

        # 1. Save Geometry
        if self.geometry:
            self.geometry.save_geometry(self.project_path)

        # 2. Save Mesh
        # We prefer to save the mesh that is currently in use by the solver
        current_mesh = self.mesh
        if self._fds and self._fds.mesh:
            current_mesh = self._fds.mesh

        if current_mesh:
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            pm = ProjectManager(self.base_dir)
            pm.save_ngs_mesh(self.mesh_path, current_mesh)

            # Also save global FES if available from solver
            if self._fds and hasattr(self._fds, '_fes_global') and self._fds._fes_global:
                pm.save_ngs_fes(self.mesh_path, self._fds._fes_global)

        # 3. Save FDS Results
        if self._fds:
            self._fds._project_path = self.project_path
            self.fds_path.mkdir(parents=True, exist_ok=True)
            self._fds.save(path=self.fds_path)

        # 4. Global Metadata
        metadata = {
            "name": self.name,
            "timestamp": datetime.now().isoformat(),
            "has_geometry": self.geometry is not None,
            "has_mesh": current_mesh is not None,
            "has_fds": self._fds is not None,
            "order": self._order,
            "n_port_modes": self._n_port_modes,
            "bc": self.bc,
        }
        ProjectManager.save_json(self.project_path, metadata, filename="project.json")

    @classmethod
    def load(cls, name: str, base_dir: Optional[Union[str, Path]] = None, overwrite: bool = False) -> EMProject:
        """Load a project from disk."""
        base_dir = Path(base_dir) if base_dir else Path.cwd()
        # The __init__ already handles searching and automatic loading
        return cls(name=name, base_dir=base_dir, overwrite=overwrite)

    def __repr__(self) -> str:
        return f"EMProject({self.name}, path={self.project_path})"

Functions

__init__(name, base_dir=None, geometry=None, bc=None, overwrite=False)

Source code in cavsim3d/core/em_project.py
def __init__(
    self,
    name: str,
    base_dir: Optional[Union[str, Path]] = None,
    geometry: Optional[BaseGeometry] = None,
    bc: str = None,
    overwrite: bool = False,
):
    self.name = name
    # Use current directory if base_dir is not provided
    self.base_dir = Path(base_dir) if base_dir else Path.cwd()
    self.project_path = self.base_dir / self.name

    # Overwrite protection: if overwrite=True, delete existing project folder
    if overwrite and self.project_path.exists():
        pr.info(f"Project '{self.name}' already exists and overwrite=True. Deleting old project...")
        shutil.rmtree(self.project_path)

    self._geometry = geometry
    self.bc = bc
    self._mesh: Optional[Mesh] = None
    self._fds: Optional[FrequencyDomainSolver] = None
    self._order = 3
    self._n_port_modes = 1
    self._loading = False  # Guard flag to prevent save() during _initial_load()

    # Automatic Loading or Creation
    if self.project_path.exists():
        pr.milestone(f"Project '{self.name}' exists. Loading...")
        self._initial_load()
        pr.milestone(f"Project '{self.name}' loaded.")
    else:
        pr.milestone(f"Creating new project '{self.name}' at {self.project_path}")
        self.project_path.mkdir(parents=True, exist_ok=True)
        self.geometry_path.mkdir(parents=True, exist_ok=True)
        self.mesh_path.mkdir(parents=True, exist_ok=True)
        self.fds_path.mkdir(parents=True, exist_ok=True)
        # Automatic save on creation if geometry is provided
        if self.geometry:
            self.save()

    # Show welcome banner in Jupyter
    self._show_welcome_banner()

create_assembly(main_axis='Z')

Create a new multi-component assembly for this project.

This sets the project's geometry to an empty Assembly and returns it. You can then add components to the assembly using assembly.add().

Parameters

main_axis : str Primary axis for concatenation ('X', 'Y', or 'Z')

Returns

Assembly The new assembly instance

Source code in cavsim3d/core/em_project.py
def create_assembly(self, main_axis: str = 'Z') -> 'Assembly':
    """
    Create a new multi-component assembly for this project.

    This sets the project's geometry to an empty Assembly and returns it.
    You can then add components to the assembly using assembly.add().

    Parameters
    ----------
    main_axis : str
        Primary axis for concatenation ('X', 'Y', or 'Z')

    Returns
    -------
    Assembly
        The new assembly instance
    """
    self.geometry = Assembly(main_axis=main_axis)
    self.save()
    return self.geometry

create_primitive(primitive_type, force=False, **kwargs)

Create a primitive geometry and associate it with the project.

Source code in cavsim3d/core/em_project.py
def create_primitive(self, primitive_type: str, force: bool = False, **kwargs) -> BaseGeometry:
    """Create a primitive geometry and associate it with the project."""
    if (self.has_mesh() or self.has_results()) and not force:
        if not get_user_confirmation(
            "\nWARNING: Geometry change will invalidate the current mesh and simulation results.\n"
            "Do you want to continue and delete existing results?"
        ):
            pr.info("Aborting primitive creation.")
            return self.geometry

        self.invalidate_mesh()

    # Map string names to classes
    mapping = {
        'rectangular_waveguide': primitives.RectangularWaveguide,
        'circular_waveguide': primitives.CircularWaveguide,
        'rwg': primitives.RectangularWaveguide,
        'cwg': primitives.CircularWaveguide,
    }

    cls = mapping.get(primitive_type.lower())
    if not cls:
        raise ValueError(f"Unknown primitive type: {primitive_type}")

    self.geometry = cls(**kwargs)
    self.save()  # Auto-save
    return self.geometry

create_importer(filepath, **kwargs)

Create an OCCImporter for a CAD file (without necessarily setting it as the project geometry).

Source code in cavsim3d/core/em_project.py
def create_importer(self, filepath: Union[str, Path], **kwargs) -> 'OCCImporter':
    """Create an OCCImporter for a CAD file (without necessarily setting it as the project geometry)."""
    return OCCImporter(str(filepath), **kwargs)

import_geometry(filepath, force=False, **kwargs)

Import geometry from a file into the project.

Source code in cavsim3d/core/em_project.py
def import_geometry(self, filepath: Union[str, Path], force: bool = False, **kwargs) -> 'OCCImporter':
    """Import geometry from a file into the project."""
    if (self.has_mesh() or self.has_results()) and not force:
        if not get_user_confirmation(
            "\nWARNING: Importing new geometry will invalidate the current mesh and simulation results.\n"
            "Do you want to continue and delete existing results?"
        ):
            pr.info("Aborting geometry import.")
            return self.geometry

        self.invalidate_mesh()

    self.geometry = self.create_importer(filepath, **kwargs)
    self.save()  # Auto-save after successful import
    return self.geometry

generate_mesh(force=False, **kwargs)

Generate mesh from current geometry.

Automatically invalidates existing simulation results if the mesh changes.

Source code in cavsim3d/core/em_project.py
def generate_mesh(self, force: bool = False, **kwargs) -> Mesh:
    """
    Generate mesh from current geometry.

    Automatically invalidates existing simulation results if the mesh changes.
    """
    if self.geometry is None:
        raise RuntimeError("Cannot generate mesh without geometry.")

    if self.has_results() and not force:
        if not get_user_confirmation(
            "\nWARNING: Re-generating the mesh will invalidate existing simulation results.\n"
            "Do you want to continue and delete existing results?"
        ):
            pr.info("Aborting mesh generation.")
            return self.mesh

        self.invalidate_results()

    self.mesh = self.geometry.generate_mesh(**kwargs)
    self.save()
    return self.mesh

save()

Save the entire project with the new folder structure.

Source code in cavsim3d/core/em_project.py
def save(self):
    """Save the entire project with the new folder structure."""
    pr.info(f"Saving project to {self.project_path}")

    # 1. Save Geometry
    if self.geometry:
        self.geometry.save_geometry(self.project_path)

    # 2. Save Mesh
    # We prefer to save the mesh that is currently in use by the solver
    current_mesh = self.mesh
    if self._fds and self._fds.mesh:
        current_mesh = self._fds.mesh

    if current_mesh:
        self.mesh_path.mkdir(parents=True, exist_ok=True)
        pm = ProjectManager(self.base_dir)
        pm.save_ngs_mesh(self.mesh_path, current_mesh)

        # Also save global FES if available from solver
        if self._fds and hasattr(self._fds, '_fes_global') and self._fds._fes_global:
            pm.save_ngs_fes(self.mesh_path, self._fds._fes_global)

    # 3. Save FDS Results
    if self._fds:
        self._fds._project_path = self.project_path
        self.fds_path.mkdir(parents=True, exist_ok=True)
        self._fds.save(path=self.fds_path)

    # 4. Global Metadata
    metadata = {
        "name": self.name,
        "timestamp": datetime.now().isoformat(),
        "has_geometry": self.geometry is not None,
        "has_mesh": current_mesh is not None,
        "has_fds": self._fds is not None,
        "order": self._order,
        "n_port_modes": self._n_port_modes,
        "bc": self.bc,
    }
    ProjectManager.save_json(self.project_path, metadata, filename="project.json")

load(name, base_dir=None, overwrite=False) classmethod

Load a project from disk.

Source code in cavsim3d/core/em_project.py
@classmethod
def load(cls, name: str, base_dir: Optional[Union[str, Path]] = None, overwrite: bool = False) -> EMProject:
    """Load a project from disk."""
    base_dir = Path(base_dir) if base_dir else Path.cwd()
    # The __init__ already handles searching and automatic loading
    return cls(name=name, base_dir=base_dir, overwrite=overwrite)

has_mesh()

Check if mesh exists (either in memory or on disk).

Source code in cavsim3d/core/em_project.py
def has_mesh(self) -> bool:
    """Check if mesh exists (either in memory or on disk)."""
    if self.mesh is not None:
        return True
    return (self.mesh_path / "mesh.pkl").exists()

has_results()

Check if any simulation results exist (either in memory or on disk).

Source code in cavsim3d/core/em_project.py
def has_results(self) -> bool:
    """Check if any simulation results exist (either in memory or on disk)."""
    # Check in memory
    if self._fds and (getattr(self._fds, '_fom_cache', None) or getattr(self._fds, '_resonant_mode_cache', None)):
        return True

    # Check on disk (config.json marks a valid simulation save)
    return (self.fds_path / "config.json").exists()

invalidate_mesh()

Invalidate the mesh and all downstream results (fom, rom, etc.).

Source code in cavsim3d/core/em_project.py
def invalidate_mesh(self) -> None:
    """Invalidate the mesh and all downstream results (fom, rom, etc.)."""
    pr.info(f"Invalidating mesh for project '{self.name}'...")

    # 1. Physical Cleanup (Content only, preserve directory)
    if self.mesh_path.exists():
        for item in self.mesh_path.iterdir():
            if item.is_dir():
                shutil.rmtree(item)
            else:
                item.unlink()

    # 2. In-Memory Reset
    self.mesh = None
    if self.geometry:
        self.geometry.mesh = None # Sync with geometry object

    # 3. Propagate Downstream
    self.invalidate_results()

invalidate_results()

Invalidate all simulation results (fom, rom, concat, etc.).

Source code in cavsim3d/core/em_project.py
def invalidate_results(self) -> None:
    """Invalidate all simulation results (fom, rom, concat, etc.)."""
    pr.info(f"Invalidating simulation results for project '{self.name}'...")

    # 1. Physical Cleanup (Content only, preserve directories)
    for path in [self.fds_path, self.fom_path, self.foms_path, self.eigenmode_path]:
        if path.exists():
            for item in path.iterdir():
                if item.is_dir():
                    shutil.rmtree(item)
                else:
                    item.unlink()

    # 2. In-Memory Reset
    if self._fds:
        # We don't delete self._fds object, but we clear its entire state.
        # Use full_reset to ensure matrices, FES, and flags are cleared.
        self._fds.full_reset()
        self._fds.mesh = self.mesh # Ensure solver still has access to new mesh if it exists

Key Properties

Central class for managing electromagnetic simulation projects.

Responsibility: - Manage the project directory structure. - Orchestrate saving and loading of geometry, mesh, and solvers. - Provide a unified entry point for simulation.

Source code in cavsim3d/core/em_project.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
class EMProject:
    """
    Central class for managing electromagnetic simulation projects.

    Responsibility:
    - Manage the project directory structure.
    - Orchestrate saving and loading of geometry, mesh, and solvers.
    - Provide a unified entry point for simulation.
    """

    def create_assembly(self, main_axis: str = 'Z') -> 'Assembly':
        """
        Create a new multi-component assembly for this project.

        This sets the project's geometry to an empty Assembly and returns it.
        You can then add components to the assembly using assembly.add().

        Parameters
        ----------
        main_axis : str
            Primary axis for concatenation ('X', 'Y', or 'Z')

        Returns
        -------
        Assembly
            The new assembly instance
        """
        self.geometry = Assembly(main_axis=main_axis)
        self.save()
        return self.geometry

    def __init__(
        self,
        name: str,
        base_dir: Optional[Union[str, Path]] = None,
        geometry: Optional[BaseGeometry] = None,
        bc: str = None,
        overwrite: bool = False,
    ):
        self.name = name
        # Use current directory if base_dir is not provided
        self.base_dir = Path(base_dir) if base_dir else Path.cwd()
        self.project_path = self.base_dir / self.name

        # Overwrite protection: if overwrite=True, delete existing project folder
        if overwrite and self.project_path.exists():
            pr.info(f"Project '{self.name}' already exists and overwrite=True. Deleting old project...")
            shutil.rmtree(self.project_path)

        self._geometry = geometry
        self.bc = bc
        self._mesh: Optional[Mesh] = None
        self._fds: Optional[FrequencyDomainSolver] = None
        self._order = 3
        self._n_port_modes = 1
        self._loading = False  # Guard flag to prevent save() during _initial_load()

        # Automatic Loading or Creation
        if self.project_path.exists():
            pr.milestone(f"Project '{self.name}' exists. Loading...")
            self._initial_load()
            pr.milestone(f"Project '{self.name}' loaded.")
        else:
            pr.milestone(f"Creating new project '{self.name}' at {self.project_path}")
            self.project_path.mkdir(parents=True, exist_ok=True)
            self.geometry_path.mkdir(parents=True, exist_ok=True)
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            self.fds_path.mkdir(parents=True, exist_ok=True)
            # Automatic save on creation if geometry is provided
            if self.geometry:
                self.save()

        # Show welcome banner in Jupyter
        self._show_welcome_banner()

    def _show_welcome_banner(self):
        """Display the cavsim3d logo in Jupyter notebooks."""
        try:
            from IPython import get_ipython
            if get_ipython() is None:
                return  # Not in IPython/Jupyter
            from IPython.display import display, SVG, HTML
            logo_path = os.path.join(
                os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
                "docs", "assets", "cavsim3d_logo_square.svg"
            )
            if os.path.exists(logo_path):
                display(HTML(f"""
                <div style="display: flex; align-items: center; gap: 12px;">
                    <img src="data:image/svg+xml;base64,{__import__('base64').b64encode(open(logo_path, 'rb').read()).decode()}" style="height: 40px;">
                    <span style="font-size: 16px; font-weight: bold; color: #e66433;">CAVSIM-3D</span>
                    <span style="font-size: 13px; color: #888;">v0.1.0 &mdash; {self.name}</span>
                </div>
                """))
        except ImportError:
            pass  # Not in Jupyter, skip silently

    def _initial_load(self):
        """Internal helper for automatic loading during instantiation."""
        metadata_file = self.project_path / "project.json"
        if not metadata_file.exists():
            return

        self._loading = True  # Prevent save() from being triggered during load

        with open(metadata_file, "r") as f:
            metadata = json.load(f)

        self.bc = metadata.get("bc", self.bc)
        self._order = metadata.get("order", self._order)
        self._n_port_modes = metadata.get("n_port_modes", self._n_port_modes)

        # 1. Load Geometry FIRST
        has_geo = metadata.get("has_geometry", False)
        # Self-healing: check if geometry folder has contents even if flag is false
        if not has_geo and self.geometry_path.exists() and any(self.geometry_path.iterdir()):
            has_geo = True

        if has_geo:
            try:
                self.geometry = BaseGeometry.load_geometry(self.project_path)
            except Exception as e:
                pr.warning(f"Could not load geometry: {e}")

        # 2. Load Mesh SECOND (needed for FDS port modes)
        has_mesh = metadata.get("has_mesh", False)
        # Self-healing: check if mesh folder has contents even if flag is false
        if not has_mesh and self.mesh_path.exists() and (self.mesh_path / "mesh.pkl").exists():
            has_mesh = True

        if has_mesh:
            pm = ProjectManager(self.base_dir)
            self.mesh = pm.load_ngs_mesh(self.mesh_path)

        # 3. Load Solver (FDS) LAST - needs mesh for port mode reconstruction
        if metadata.get("has_fds"):

            # Pass mesh to load method so port modes can be reconstructed
            self._fds = FrequencyDomainSolver.load_from_path(
                self.fds_path,
                geometry=self.geometry,
                mesh=self.mesh,  # Pass mesh here
                order=self._order,
                bc=self.bc
            )

            if self._fds:
                self._fds._project_path = self.project_path
                # Only sync mesh if the FDS doesn't have one already (load_from_path handles it)
                if self.mesh and self._fds.mesh is None:
                    self._fds.mesh = self.mesh
                    # Restore FES
                    pm = ProjectManager(self.base_dir)
                    fes = pm.load_ngs_fes(self.fds_path)
                    if fes:
                        self._fds._fes_global = fes

        self._loading = False  # Re-enable save()

    @property
    def geo(self) -> Optional[BaseGeometry]:
        """Shortcut for geometry."""
        return self.geometry

    @property
    def fds(self) -> FrequencyDomainSolver:
        """Lazy initialization of the FrequencyDomainSolver."""
        if self._fds is None:
            if self.geometry is None:
                raise RuntimeError("Cannot initialize solver without geometry.")
            from cavsim3d.solvers.frequency_domain import FrequencyDomainSolver
            self._fds = FrequencyDomainSolver(
                geometry=self.geometry,
                order=self.order,
                bc=self.bc
            )
            self._fds._project_path = self.project_path
            self._fds._project_name = self.name
            self._fds._project_ref = self
        return self._fds

    @fds.setter
    def fds(self, value: Optional[FrequencyDomainSolver]):
        self._fds = value
        if value:
            value._project_path = self.project_path
            value._project_name = self.name
            value._project_ref = self
            value.order = self._order
            value.n_port_modes = self._n_port_modes

    def import_geometry(self, filepath: Union[str, Path], force: bool = False, **kwargs) -> 'OCCImporter':
        """Import geometry from a file into the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Importing new geometry will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting geometry import.")
                return self.geometry

            self.invalidate_mesh()

        self.geometry = self.create_importer(filepath, **kwargs)
        self.save()  # Auto-save after successful import
        return self.geometry

    def create_importer(self, filepath: Union[str, Path], **kwargs) -> 'OCCImporter':
        """Create an OCCImporter for a CAD file (without necessarily setting it as the project geometry)."""
        return OCCImporter(str(filepath), **kwargs)

    def create_primitive(self, primitive_type: str, force: bool = False, **kwargs) -> BaseGeometry:
        """Create a primitive geometry and associate it with the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Geometry change will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting primitive creation.")
                return self.geometry

            self.invalidate_mesh()

        # Map string names to classes
        mapping = {
            'rectangular_waveguide': primitives.RectangularWaveguide,
            'circular_waveguide': primitives.CircularWaveguide,
            'rwg': primitives.RectangularWaveguide,
            'cwg': primitives.CircularWaveguide,
        }

        cls = mapping.get(primitive_type.lower())
        if not cls:
            raise ValueError(f"Unknown primitive type: {primitive_type}")

        self.geometry = cls(**kwargs)
        self.save()  # Auto-save
        return self.geometry

    def generate_mesh(self, force: bool = False, **kwargs) -> Mesh:
        """
        Generate mesh from current geometry.

        Automatically invalidates existing simulation results if the mesh changes.
        """
        if self.geometry is None:
            raise RuntimeError("Cannot generate mesh without geometry.")

        if self.has_results() and not force:
            if not get_user_confirmation(
                "\nWARNING: Re-generating the mesh will invalidate existing simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting mesh generation.")
                return self.mesh

            self.invalidate_results()

        self.mesh = self.geometry.generate_mesh(**kwargs)
        self.save()
        return self.mesh

    def has_mesh(self) -> bool:
        """Check if mesh exists (either in memory or on disk)."""
        if self.mesh is not None:
            return True
        return (self.mesh_path / "mesh.pkl").exists()

    def has_results(self) -> bool:
        """Check if any simulation results exist (either in memory or on disk)."""
        # Check in memory
        if self._fds and (getattr(self._fds, '_fom_cache', None) or getattr(self._fds, '_resonant_mode_cache', None)):
            return True

        # Check on disk (config.json marks a valid simulation save)
        return (self.fds_path / "config.json").exists()

    def invalidate_mesh(self) -> None:
        """Invalidate the mesh and all downstream results (fom, rom, etc.)."""
        pr.info(f"Invalidating mesh for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directory)
        if self.mesh_path.exists():
            for item in self.mesh_path.iterdir():
                if item.is_dir():
                    shutil.rmtree(item)
                else:
                    item.unlink()

        # 2. In-Memory Reset
        self.mesh = None
        if self.geometry:
            self.geometry.mesh = None # Sync with geometry object

        # 3. Propagate Downstream
        self.invalidate_results()

    def invalidate_results(self) -> None:
        """Invalidate all simulation results (fom, rom, concat, etc.)."""
        pr.info(f"Invalidating simulation results for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directories)
        for path in [self.fds_path, self.fom_path, self.foms_path, self.eigenmode_path]:
            if path.exists():
                for item in path.iterdir():
                    if item.is_dir():
                        shutil.rmtree(item)
                    else:
                        item.unlink()

        # 2. In-Memory Reset
        if self._fds:
            # We don't delete self._fds object, but we clear its entire state.
            # Use full_reset to ensure matrices, FES, and flags are cleared.
            self._fds.full_reset()
            self._fds.mesh = self.mesh # Ensure solver still has access to new mesh if it exists

    @property
    def geometry(self) -> Optional[BaseGeometry]:
        """Current project geometry."""
        return self._geometry

    @geometry.setter
    def geometry(self, value: Optional[BaseGeometry]):
        self._geometry = value
        # Sync solver with new geometry object
        if self._fds:
            self._fds.geometry = value

    @property
    def mesh(self) -> Optional[Mesh]:
        """Project mesh (NGSolve Mesh object)."""
        return self._mesh

    @mesh.setter
    def mesh(self, value: Optional[Mesh]):
        self._mesh = value
        if self._fds:
            self._fds.mesh = value

        # Auto-save mesh to disk if it exists (but NOT during _initial_load)
        if value is not None and not getattr(self, '_loading', False):
            self.save()

    @property
    def order(self) -> int:
        return self._order

    @order.setter
    def order(self, value: int):
        self._order = value
        if self._fds:
            self._fds.order = value

    @property
    def n_port_modes(self) -> int:
        return self._n_port_modes

    @n_port_modes.setter
    def n_port_modes(self, value: int):
        self._n_port_modes = value
        if self._fds:
            self._fds.n_port_modes = value

    @property
    def mesh_path(self) -> Path:
        return self.project_path / "mesh"

    @property
    def fds_path(self) -> Path:
        return self.project_path / "fds"

    @property
    def geometry_path(self) -> Path:
        return self.project_path / "geometry"

    @property
    def fom_path(self) -> Path:
        return self.project_path / "fom"

    @property
    def foms_path(self) -> Path:
        return self.project_path / "foms"

    @property
    def eigenmode_path(self) -> Path:
        return self.project_path / "eigenmode"

    def save(self):
        """Save the entire project with the new folder structure."""
        pr.info(f"Saving project to {self.project_path}")

        # 1. Save Geometry
        if self.geometry:
            self.geometry.save_geometry(self.project_path)

        # 2. Save Mesh
        # We prefer to save the mesh that is currently in use by the solver
        current_mesh = self.mesh
        if self._fds and self._fds.mesh:
            current_mesh = self._fds.mesh

        if current_mesh:
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            pm = ProjectManager(self.base_dir)
            pm.save_ngs_mesh(self.mesh_path, current_mesh)

            # Also save global FES if available from solver
            if self._fds and hasattr(self._fds, '_fes_global') and self._fds._fes_global:
                pm.save_ngs_fes(self.mesh_path, self._fds._fes_global)

        # 3. Save FDS Results
        if self._fds:
            self._fds._project_path = self.project_path
            self.fds_path.mkdir(parents=True, exist_ok=True)
            self._fds.save(path=self.fds_path)

        # 4. Global Metadata
        metadata = {
            "name": self.name,
            "timestamp": datetime.now().isoformat(),
            "has_geometry": self.geometry is not None,
            "has_mesh": current_mesh is not None,
            "has_fds": self._fds is not None,
            "order": self._order,
            "n_port_modes": self._n_port_modes,
            "bc": self.bc,
        }
        ProjectManager.save_json(self.project_path, metadata, filename="project.json")

    @classmethod
    def load(cls, name: str, base_dir: Optional[Union[str, Path]] = None, overwrite: bool = False) -> EMProject:
        """Load a project from disk."""
        base_dir = Path(base_dir) if base_dir else Path.cwd()
        # The __init__ already handles searching and automatic loading
        return cls(name=name, base_dir=base_dir, overwrite=overwrite)

    def __repr__(self) -> str:
        return f"EMProject({self.name}, path={self.project_path})"

geometry property writable

Current project geometry.

mesh property writable

Project mesh (NGSolve Mesh object).

fds property writable

Lazy initialization of the FrequencyDomainSolver.

order property writable

n_port_modes property writable

geo property

Shortcut for geometry.

Persistence Paths

Central class for managing electromagnetic simulation projects.

Responsibility: - Manage the project directory structure. - Orchestrate saving and loading of geometry, mesh, and solvers. - Provide a unified entry point for simulation.

Source code in cavsim3d/core/em_project.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
class EMProject:
    """
    Central class for managing electromagnetic simulation projects.

    Responsibility:
    - Manage the project directory structure.
    - Orchestrate saving and loading of geometry, mesh, and solvers.
    - Provide a unified entry point for simulation.
    """

    def create_assembly(self, main_axis: str = 'Z') -> 'Assembly':
        """
        Create a new multi-component assembly for this project.

        This sets the project's geometry to an empty Assembly and returns it.
        You can then add components to the assembly using assembly.add().

        Parameters
        ----------
        main_axis : str
            Primary axis for concatenation ('X', 'Y', or 'Z')

        Returns
        -------
        Assembly
            The new assembly instance
        """
        self.geometry = Assembly(main_axis=main_axis)
        self.save()
        return self.geometry

    def __init__(
        self,
        name: str,
        base_dir: Optional[Union[str, Path]] = None,
        geometry: Optional[BaseGeometry] = None,
        bc: str = None,
        overwrite: bool = False,
    ):
        self.name = name
        # Use current directory if base_dir is not provided
        self.base_dir = Path(base_dir) if base_dir else Path.cwd()
        self.project_path = self.base_dir / self.name

        # Overwrite protection: if overwrite=True, delete existing project folder
        if overwrite and self.project_path.exists():
            pr.info(f"Project '{self.name}' already exists and overwrite=True. Deleting old project...")
            shutil.rmtree(self.project_path)

        self._geometry = geometry
        self.bc = bc
        self._mesh: Optional[Mesh] = None
        self._fds: Optional[FrequencyDomainSolver] = None
        self._order = 3
        self._n_port_modes = 1
        self._loading = False  # Guard flag to prevent save() during _initial_load()

        # Automatic Loading or Creation
        if self.project_path.exists():
            pr.milestone(f"Project '{self.name}' exists. Loading...")
            self._initial_load()
            pr.milestone(f"Project '{self.name}' loaded.")
        else:
            pr.milestone(f"Creating new project '{self.name}' at {self.project_path}")
            self.project_path.mkdir(parents=True, exist_ok=True)
            self.geometry_path.mkdir(parents=True, exist_ok=True)
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            self.fds_path.mkdir(parents=True, exist_ok=True)
            # Automatic save on creation if geometry is provided
            if self.geometry:
                self.save()

        # Show welcome banner in Jupyter
        self._show_welcome_banner()

    def _show_welcome_banner(self):
        """Display the cavsim3d logo in Jupyter notebooks."""
        try:
            from IPython import get_ipython
            if get_ipython() is None:
                return  # Not in IPython/Jupyter
            from IPython.display import display, SVG, HTML
            logo_path = os.path.join(
                os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
                "docs", "assets", "cavsim3d_logo_square.svg"
            )
            if os.path.exists(logo_path):
                display(HTML(f"""
                <div style="display: flex; align-items: center; gap: 12px;">
                    <img src="data:image/svg+xml;base64,{__import__('base64').b64encode(open(logo_path, 'rb').read()).decode()}" style="height: 40px;">
                    <span style="font-size: 16px; font-weight: bold; color: #e66433;">CAVSIM-3D</span>
                    <span style="font-size: 13px; color: #888;">v0.1.0 &mdash; {self.name}</span>
                </div>
                """))
        except ImportError:
            pass  # Not in Jupyter, skip silently

    def _initial_load(self):
        """Internal helper for automatic loading during instantiation."""
        metadata_file = self.project_path / "project.json"
        if not metadata_file.exists():
            return

        self._loading = True  # Prevent save() from being triggered during load

        with open(metadata_file, "r") as f:
            metadata = json.load(f)

        self.bc = metadata.get("bc", self.bc)
        self._order = metadata.get("order", self._order)
        self._n_port_modes = metadata.get("n_port_modes", self._n_port_modes)

        # 1. Load Geometry FIRST
        has_geo = metadata.get("has_geometry", False)
        # Self-healing: check if geometry folder has contents even if flag is false
        if not has_geo and self.geometry_path.exists() and any(self.geometry_path.iterdir()):
            has_geo = True

        if has_geo:
            try:
                self.geometry = BaseGeometry.load_geometry(self.project_path)
            except Exception as e:
                pr.warning(f"Could not load geometry: {e}")

        # 2. Load Mesh SECOND (needed for FDS port modes)
        has_mesh = metadata.get("has_mesh", False)
        # Self-healing: check if mesh folder has contents even if flag is false
        if not has_mesh and self.mesh_path.exists() and (self.mesh_path / "mesh.pkl").exists():
            has_mesh = True

        if has_mesh:
            pm = ProjectManager(self.base_dir)
            self.mesh = pm.load_ngs_mesh(self.mesh_path)

        # 3. Load Solver (FDS) LAST - needs mesh for port mode reconstruction
        if metadata.get("has_fds"):

            # Pass mesh to load method so port modes can be reconstructed
            self._fds = FrequencyDomainSolver.load_from_path(
                self.fds_path,
                geometry=self.geometry,
                mesh=self.mesh,  # Pass mesh here
                order=self._order,
                bc=self.bc
            )

            if self._fds:
                self._fds._project_path = self.project_path
                # Only sync mesh if the FDS doesn't have one already (load_from_path handles it)
                if self.mesh and self._fds.mesh is None:
                    self._fds.mesh = self.mesh
                    # Restore FES
                    pm = ProjectManager(self.base_dir)
                    fes = pm.load_ngs_fes(self.fds_path)
                    if fes:
                        self._fds._fes_global = fes

        self._loading = False  # Re-enable save()

    @property
    def geo(self) -> Optional[BaseGeometry]:
        """Shortcut for geometry."""
        return self.geometry

    @property
    def fds(self) -> FrequencyDomainSolver:
        """Lazy initialization of the FrequencyDomainSolver."""
        if self._fds is None:
            if self.geometry is None:
                raise RuntimeError("Cannot initialize solver without geometry.")
            from cavsim3d.solvers.frequency_domain import FrequencyDomainSolver
            self._fds = FrequencyDomainSolver(
                geometry=self.geometry,
                order=self.order,
                bc=self.bc
            )
            self._fds._project_path = self.project_path
            self._fds._project_name = self.name
            self._fds._project_ref = self
        return self._fds

    @fds.setter
    def fds(self, value: Optional[FrequencyDomainSolver]):
        self._fds = value
        if value:
            value._project_path = self.project_path
            value._project_name = self.name
            value._project_ref = self
            value.order = self._order
            value.n_port_modes = self._n_port_modes

    def import_geometry(self, filepath: Union[str, Path], force: bool = False, **kwargs) -> 'OCCImporter':
        """Import geometry from a file into the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Importing new geometry will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting geometry import.")
                return self.geometry

            self.invalidate_mesh()

        self.geometry = self.create_importer(filepath, **kwargs)
        self.save()  # Auto-save after successful import
        return self.geometry

    def create_importer(self, filepath: Union[str, Path], **kwargs) -> 'OCCImporter':
        """Create an OCCImporter for a CAD file (without necessarily setting it as the project geometry)."""
        return OCCImporter(str(filepath), **kwargs)

    def create_primitive(self, primitive_type: str, force: bool = False, **kwargs) -> BaseGeometry:
        """Create a primitive geometry and associate it with the project."""
        if (self.has_mesh() or self.has_results()) and not force:
            if not get_user_confirmation(
                "\nWARNING: Geometry change will invalidate the current mesh and simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting primitive creation.")
                return self.geometry

            self.invalidate_mesh()

        # Map string names to classes
        mapping = {
            'rectangular_waveguide': primitives.RectangularWaveguide,
            'circular_waveguide': primitives.CircularWaveguide,
            'rwg': primitives.RectangularWaveguide,
            'cwg': primitives.CircularWaveguide,
        }

        cls = mapping.get(primitive_type.lower())
        if not cls:
            raise ValueError(f"Unknown primitive type: {primitive_type}")

        self.geometry = cls(**kwargs)
        self.save()  # Auto-save
        return self.geometry

    def generate_mesh(self, force: bool = False, **kwargs) -> Mesh:
        """
        Generate mesh from current geometry.

        Automatically invalidates existing simulation results if the mesh changes.
        """
        if self.geometry is None:
            raise RuntimeError("Cannot generate mesh without geometry.")

        if self.has_results() and not force:
            if not get_user_confirmation(
                "\nWARNING: Re-generating the mesh will invalidate existing simulation results.\n"
                "Do you want to continue and delete existing results?"
            ):
                pr.info("Aborting mesh generation.")
                return self.mesh

            self.invalidate_results()

        self.mesh = self.geometry.generate_mesh(**kwargs)
        self.save()
        return self.mesh

    def has_mesh(self) -> bool:
        """Check if mesh exists (either in memory or on disk)."""
        if self.mesh is not None:
            return True
        return (self.mesh_path / "mesh.pkl").exists()

    def has_results(self) -> bool:
        """Check if any simulation results exist (either in memory or on disk)."""
        # Check in memory
        if self._fds and (getattr(self._fds, '_fom_cache', None) or getattr(self._fds, '_resonant_mode_cache', None)):
            return True

        # Check on disk (config.json marks a valid simulation save)
        return (self.fds_path / "config.json").exists()

    def invalidate_mesh(self) -> None:
        """Invalidate the mesh and all downstream results (fom, rom, etc.)."""
        pr.info(f"Invalidating mesh for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directory)
        if self.mesh_path.exists():
            for item in self.mesh_path.iterdir():
                if item.is_dir():
                    shutil.rmtree(item)
                else:
                    item.unlink()

        # 2. In-Memory Reset
        self.mesh = None
        if self.geometry:
            self.geometry.mesh = None # Sync with geometry object

        # 3. Propagate Downstream
        self.invalidate_results()

    def invalidate_results(self) -> None:
        """Invalidate all simulation results (fom, rom, concat, etc.)."""
        pr.info(f"Invalidating simulation results for project '{self.name}'...")

        # 1. Physical Cleanup (Content only, preserve directories)
        for path in [self.fds_path, self.fom_path, self.foms_path, self.eigenmode_path]:
            if path.exists():
                for item in path.iterdir():
                    if item.is_dir():
                        shutil.rmtree(item)
                    else:
                        item.unlink()

        # 2. In-Memory Reset
        if self._fds:
            # We don't delete self._fds object, but we clear its entire state.
            # Use full_reset to ensure matrices, FES, and flags are cleared.
            self._fds.full_reset()
            self._fds.mesh = self.mesh # Ensure solver still has access to new mesh if it exists

    @property
    def geometry(self) -> Optional[BaseGeometry]:
        """Current project geometry."""
        return self._geometry

    @geometry.setter
    def geometry(self, value: Optional[BaseGeometry]):
        self._geometry = value
        # Sync solver with new geometry object
        if self._fds:
            self._fds.geometry = value

    @property
    def mesh(self) -> Optional[Mesh]:
        """Project mesh (NGSolve Mesh object)."""
        return self._mesh

    @mesh.setter
    def mesh(self, value: Optional[Mesh]):
        self._mesh = value
        if self._fds:
            self._fds.mesh = value

        # Auto-save mesh to disk if it exists (but NOT during _initial_load)
        if value is not None and not getattr(self, '_loading', False):
            self.save()

    @property
    def order(self) -> int:
        return self._order

    @order.setter
    def order(self, value: int):
        self._order = value
        if self._fds:
            self._fds.order = value

    @property
    def n_port_modes(self) -> int:
        return self._n_port_modes

    @n_port_modes.setter
    def n_port_modes(self, value: int):
        self._n_port_modes = value
        if self._fds:
            self._fds.n_port_modes = value

    @property
    def mesh_path(self) -> Path:
        return self.project_path / "mesh"

    @property
    def fds_path(self) -> Path:
        return self.project_path / "fds"

    @property
    def geometry_path(self) -> Path:
        return self.project_path / "geometry"

    @property
    def fom_path(self) -> Path:
        return self.project_path / "fom"

    @property
    def foms_path(self) -> Path:
        return self.project_path / "foms"

    @property
    def eigenmode_path(self) -> Path:
        return self.project_path / "eigenmode"

    def save(self):
        """Save the entire project with the new folder structure."""
        pr.info(f"Saving project to {self.project_path}")

        # 1. Save Geometry
        if self.geometry:
            self.geometry.save_geometry(self.project_path)

        # 2. Save Mesh
        # We prefer to save the mesh that is currently in use by the solver
        current_mesh = self.mesh
        if self._fds and self._fds.mesh:
            current_mesh = self._fds.mesh

        if current_mesh:
            self.mesh_path.mkdir(parents=True, exist_ok=True)
            pm = ProjectManager(self.base_dir)
            pm.save_ngs_mesh(self.mesh_path, current_mesh)

            # Also save global FES if available from solver
            if self._fds and hasattr(self._fds, '_fes_global') and self._fds._fes_global:
                pm.save_ngs_fes(self.mesh_path, self._fds._fes_global)

        # 3. Save FDS Results
        if self._fds:
            self._fds._project_path = self.project_path
            self.fds_path.mkdir(parents=True, exist_ok=True)
            self._fds.save(path=self.fds_path)

        # 4. Global Metadata
        metadata = {
            "name": self.name,
            "timestamp": datetime.now().isoformat(),
            "has_geometry": self.geometry is not None,
            "has_mesh": current_mesh is not None,
            "has_fds": self._fds is not None,
            "order": self._order,
            "n_port_modes": self._n_port_modes,
            "bc": self.bc,
        }
        ProjectManager.save_json(self.project_path, metadata, filename="project.json")

    @classmethod
    def load(cls, name: str, base_dir: Optional[Union[str, Path]] = None, overwrite: bool = False) -> EMProject:
        """Load a project from disk."""
        base_dir = Path(base_dir) if base_dir else Path.cwd()
        # The __init__ already handles searching and automatic loading
        return cls(name=name, base_dir=base_dir, overwrite=overwrite)

    def __repr__(self) -> str:
        return f"EMProject({self.name}, path={self.project_path})"

mesh_path property

fds_path property

geometry_path property

fom_path property

foms_path property

eigenmode_path property