How To: LevelOpt

  • LevelOpt is another ScenarioDescriptor (LevelOptScenarioDescriptor) for level set structural topology optimization. It supports all of the standard linear elastic boundary conditions and a variety of metadata optimization parameters.

    • LevelOpt scenario is created using LevelOptScenarioDescriptor. It takes the following shared inputs from previous ScenarioDescriptor classes:

      • boundary_conditions note, with LevelOpt, multiple load cases can be input to a single optimization scenario using unique load_case_id. This allows LevelOpt to consider each load individually rather than the net load.

        # multiple load case optimization needs unique load_case_ids
        fixed_boundary1 = FixedBoundaryDescriptor()
        fixed_boundary1.boundary = MeshModel("fixed.ply")
        fixed_boundary1.load_case_id = 0
        
        fixed_boundary2 = FixedBoundaryDescriptor()
        fixed_boundary2.boundary = MeshModel("fixed.ply")
        fixed_boundary2.load_case_id = 1
        
        fixed_boundaries = [fixed_boundary1, fixed_boundary2]
        
        load1 = VectorForceDescriptor()
        load1.boundary = MeshModel("load1.ply")
        load1.direction = [1, 0, 0]
        load1.magnitude = 1000
        load1.load_case_id = 0
        
        load2 = VectorForceDescriptor()
        load2.boundary = MeshModel("load1.ply")
        load2.direction = [0, 1, 0]
        load2.magnitude = 1000
        load2.load_case_id = 1
        
        load_cases = [load1, load2]
        
      • internal_conditions

      • resolution or cell_size

      • units

      • solver_type used in the initial linear elastic simulation with the following options:

        • MKL_PardisoLDLT (default) direct solver

        • AMGCL_amg_rigid_body iterative solver

      • basis_order used in the initial linear elastic simulation

LevelOpt Metadata Settings

  • The set of metadata parameters specific to the LevelOptScenarioDescriptor include:

    • vol_frac_cons (volume fraction constraint) sets the target volume of the final design as a fraction of the initial volume.

    • voxelSize, or level set cell size, determines the size of the level set grid cells as a fraction of the FEA grid cell size.

    • move_limit controls the extent of changes per optimization step, as a factor of the voxelSize.

    • opt_max_iter (optimization max iterations) sets the maximum number of iterations for the optimization process. Each iteration refines the design by updating the topology based on the objective function and constraints.

    • fix_thickness specifies the region around boundary conditions that remains unchanged as a factor of level set grid cell size.

    • smooth_iter defines the frequency the geometry is smoothed during the optimization process as a number of iterations.

    • enable_fixed_interfaces allows specifying if the interface between the design domain (optimized) and non-design domain should be fully preserved. True preserves the interface and False allows material to be removed at the interface.

    • num_load_cases is a input required for an optimization scenario with multiple load_case_id. This enables individual load cases to be considered separately during optimization instead of a net load.

    • objective_functions sets the objective function for the optimization. This is a dictionary where the key is the load case ID and the value is the objective function type. Supported types are ObjectiveFunctionType.Compliance (default) and ObjectiveFunctionType.MaximumStress.

      Note

      The MaximumStress objective function is currently in a beta stage. Due to the nature of stress optimization, it may not be suitable for all problems. It performs best on problems with clear stress concentrations away from restraints and boundary conditions. Using it in other cases may lead to non-intuitive or unusable designs. For best results with stress optimization, we recommend using higher fidelity models (finer cell_size or voxelSize) and a lower move_limit (less than 1.0, or approximately, 0.1 to 0.5). Further, we recommend increasing fix_thickness or creating non-design domain regions near boundaries.

    • optimizer_override allows overriding the default optimization solver. The default is OptimizerType.Intact. Another option is OptimizerType.NLOpt.

    • weights allows specifying the weight for each load case in a multi-load case optimization. This is a dictionary where the key is the load case ID and the value is the weight.

      Note

      The weights metadata is currently in a beta stage. A 0.5/0.5 weighting between load cases, especially those with different objective_functions, may not result in an equal balance. Any weight can be input, and it is useful to query the boundary sensitivities to understand the order of magnitude for each load case’s sensitivity to inform the weighting. Using compliance to stabilize the optimization can also lead to better designs; specifically, using a small compliance weight (e.g., 1e-3 to 1e-5) relative to a 1.0 weight for a stress objective can lead to more stable designs while still minimizing stress.

    # Setup the LevelOpt optimization scenario descriptor
    leveloptscenario = LevelOptScenarioDescriptor()
    
    # Standard scenario parameters
    leveloptscenario.materials = {"Aluminum": material}
    leveloptscenario.metadata.cell_size = 2.5
    leveloptscenario.metadata.units = UnitSystem.MeterKilogramSecond
    leveloptscenario.metadata.solver_override = "MKL_PardisoLDLT"   # direct LinearElastic solver
    leveloptscenario.metadata.basis_order = 1                   # linear order for LinearElastic solver
    leveloptscenario.boundary_conditions = fixed_boundaries + load_cases    # consist of two sets of load cases
    
    # LevelOpt specific metadata
    leveloptscenario.optimization_metadata.vol_frac_cons = 0.2  # 20% target volume
    leveloptscenario.optimization_metadata.voxelSize = 0.5      # 0.5 (default) level set cell size factor
    leveloptscenario.optimization_metadata.move_limit = 1.0     # 1.0 (default) move limit factor
    leveloptscenario.optimization_metadata.opt_max_iter = 10    # 10 design iterations
    leveloptscenario.optimization_metadata.fix_thickness = 4  # 4 (default) level set cells which are unchanged near BCs
    leveloptscenario.optimization_metadata.smooth_iter = 1      # smoothing performed at every 1 iteration
    leveloptscenario.optimization_metadata.enable_fixed_interfaces = True   # retain full contact between assembly components
    leveloptscenario.optimization_metadata.num_load_cases = 2   # 2 load cases created
    leveloptscenario.optimization_metadata.objective_functions = {0: ObjectiveFunctionType.MaximumStress, 1: ObjectiveFunctionType.Compliance} # set objectives for each load case
    leveloptscenario.optimization_metadata.weights = {0: 10, 1: 1} # set weights for each load case
    leveloptscenario.optimization_metadata.optimizer_override = OptimizerType.NLOpt # override default optimizer
    

LevelOpt Optimization and Supported Queries

  • To run an optimization scenario a design domain Material Domain must first be defined. For assemblies, additional non-design Material Domain(s) need to be defined. Note, the design domain MeshModel requires a unique instance_id.

    # make sure to include instance_id for design_geometry/design_domain
    design_geometry = MeshModel("design_domain.stl")
    design_geometry.instance_id = "ex_design_domain"   # can be any unique id/name
    design_geometry.refine()
    
    # Create material domains and assembly
    design_domain = MaterialDomain(design_geometry, "Aluminum", leveloptscenario)
    component1 = MaterialDomain(body1, "Aluminum", leveloptscenario)
    component2 = MaterialDomain(body2, "Aluminum", leveloptscenario)
    
    assembly = [design_domain, component1, component2]
    
  • The LevelOpt optimization solver then takes the following arguments:

    • design Material Domain

    • assembly or list of all design and non-design Material Domain(s)

    • LevelOptScenarioDescriptor which describes the optimization scenario parameters and inputs.

    • starting design MeshModel, or if no starting design is used, a None argument.

    # Create and run the simulation
    optimizer = LevelOpt(design_domain, assembly, leveloptscenario, None)
    optimizer.optimize()
    
    # Create and run the simulation with optional starting design
    starting_design = MeshModel("starting_design.ply")
    optimizer = LevelOpt(design_domain, assembly, leveloptscenario, starting_design)
    optimizer.optimize()
    
  • LevelOpt also has an optimize() override that takes a callback function that operates on per-iteration data. The following example defines a callback function that writes the current iteration’s design to a file:

    # An example showing how to use a callback function to LevelOpt.optimize().
    def callback(stat: list[LevelOptIterationInfo], model: MeshModel):
        iteration_number = stat[0].iteration
        model.writePLY(f"design_{iteration_number}.ply")
    
    # code to setup scenario not shown
    
    # Create and run the simulation
    optimizer = LevelOpt(design_domain, assembly, leveloptscenario, None)
    optimizer.optimize(callback)
    

    The first argument is a list of LevelOptIterationInfo objects, one per load case, for the current design iteration. Each LevelOptIterationInfo has the compliance, iteration number, load case identifier, maximum displacement, maximum von Mises stress, and the volume fraction for the current design.

  • LevelOpt Queries include support for two GlobalQueryType classes, Compliance and VolumeFraction and two FieldQuery classes, BoundarySensitivity and BoundaryVelocity. A discrete index argument corresponding to the optimization iteration is required to query for any of the previously described quantities.

    # LevelOpt global queries
    compliance_query = GlobalQuery(GlobalQueryType.Compliance, DiscreteIndex(iteration))
    volume_fraction_query = GlobalQuery(GlobalQueryType.VolumeFraction, DiscreteIndex(iteration))
    
    compliance = optimizer.sample(compliance_query)
    volume_fraction = optimizer.sample(volume_fraction_query)
    
    compliance_value = compliance.get(0,0)
    volume_fraction_value = volume_fraction.get(0,0)
    
    # LevelOpt field queries
    boundary_sensitivity_query = FieldQuery(f=Field.BoundarySensitivity, scheme=DiscreteIndex(0))
    boundary_velocity_query = FieldQuery(f=Field.BoundaryVelocity, scheme=DiscreteIndex(0))
    
    r_s = optimizer.sample(boundary_sensitivity_query) # no QueryResult() object, only defined on design boundary
    r_v = optimizer.sample(boundary_velocity_query) # no QueryResult() object, only defined on design boundary
    
    sensitivity_value = r_s.get(0,0) # tuple of dimension 1 for each point
    velocity_value = r_v.get(0,0) # tuple of dimension 1 for each point
    
  • Output mesh designs can be stored using .getDesigns() from the LevelOpt scenario optimization and saved optionally as .ply files in a desired directory with .writePLY()

    # run LevelOpt Scenario
    optimizer = LevelOpt(design_domain, assembly, leveloptscenario, None)
    optimizer.optimize()
    
    # get the design iterations from the optimization scenario
    designs = optimizer.getDesigns()
    
    # write the design output mesh .ply files to desired directory
    for i, design in enumerate(designs):
       ply_filename = os.path.join(ply_dir, f"optimized_design_{i}.ply")
       design.writePLY(ply_filename)