Source code for aerosandbox.common

import aerosandbox.numpy as np
from aerosandbox.optimization.opti import Opti
from abc import abstractmethod, ABC
import copy
from typing import Dict, Any, Union
import casadi as cas
import dill
from pathlib import Path
import sys
import warnings


[docs]class AeroSandboxObject(ABC):
[docs] _asb_metadata: Dict[str, str] = None
@abstractmethod def __init__(self): """ Denotes AeroSandboxObject as an abstract class, meaning you can't instantiate it directly - you must subclass (extend) it instead. """ pass
[docs] def __eq__(self, other): """ Checks if two AeroSandbox objects are value-equivalent. A more sensible default for classes that represent physical objects than checking for memory equivalence. This is done by checking if the two objects are of the same type and have the same __dict__. Args: other: Another object. Returns: bool: True if the objects are equal, False otherwise. """ if self is other: # If they point to the same object in memory, they're equal return True if type(self) != type(other): # If they are of different types, they cannot be equal return False if set(self.__dict__.keys()) != set( other.__dict__.keys()): # If they have differing dict keys, don't bother checking values return False for key in self.__dict__.keys(): # Check equality of all values if np.all(self.__dict__[key] == other.__dict__[key]): continue else: return False return True
[docs] def save(self, filename: Union[str, Path] = None, verbose: bool = True, automatically_add_extension: bool = True, ) -> None: """ Saves the object to a binary file, using the `dill` library. Creates a .asb file, which is a binary file that can be loaded with `aerosandbox.load()`. This can be loaded into memory in a different Python session or a different computer, and it will be exactly the same as when it was saved. Args: filename: The filename to save this object to. Should be a .asb file. verbose: If True, prints messages to console on successful save. automatically_add_extension: If True, automatically adds the .asb extension to the filename if it doesn't already have it. If False, does not add the extension. Returns: None (writes to file) """ if filename is None: try: filename = self.name except AttributeError: filename = "untitled" filename = Path(filename) if filename.suffix == "" and automatically_add_extension: filename = filename.with_suffix(".asb") if verbose: print(f"Saving {str(self)} to:\n\t{filename}...") import aerosandbox as asb self._asb_metadata = { "python_version": ".".join([ str(sys.version_info.major), str(sys.version_info.minor), str(sys.version_info.micro), ]), "asb_version" : asb.__version__ } with open(filename, "wb") as f: dill.dump( obj=self, file=f, )
[docs] def copy(self): """ Returns a shallow copy of the object. """ return copy.copy(self)
[docs] def deepcopy(self): """ Returns a deep copy of the object. """ return copy.deepcopy(self)
[docs] def substitute_solution(self, sol: cas.OptiSol, inplace: bool = None, ): """ Substitutes a solution from CasADi's solver recursively as an in-place operation. In-place operation. To make it not in-place, do `y = copy.deepcopy(x)` or similar first. :param sol: OptiSol object. :return: """ import warnings warnings.warn( "This function is deprecated and will break at some future point.\n" "Use `sol(x)`, which now works recursively on complex data structures.", DeprecationWarning ) # Set defaults if inplace is None: inplace = True def convert(item): """ This is essentially a supercharged version of sol(), which works for more iterable types. Args: item: Returns: """ # If it can be converted, do the conversion. if np.is_casadi_type(item, recursive=False): return sol(item) t = type(item) # If it's a Python iterable, recursively convert it, and preserve the type as best as possible. if issubclass(t, list): return [convert(i) for i in item] if issubclass(t, tuple): return tuple([convert(i) for i in item]) if issubclass(t, set) or issubclass(t, frozenset): return {convert(i) for i in item} if issubclass(t, dict): return { convert(k): convert(v) for k, v in item.items() } # Skip certain Python types for type_to_skip in ( bool, str, int, float, complex, range, type(None), bytes, bytearray, memoryview ): if issubclass(t, type_to_skip): return item # Skip certain CasADi types for type_to_skip in ( cas.Opti, cas.OptiSol ): if issubclass(t, type_to_skip): return item # If it's any other type, try converting its attribute dictionary: try: newdict = { k: convert(v) for k, v in item.__dict__.items() } if inplace: for k, v in newdict.items(): setattr(item, k, v) return item else: newitem = copy.copy(item) for k, v in newdict.items(): setattr(newitem, k, v) return newitem except AttributeError: pass # Try converting it blindly. This will catch most NumPy-array-like types. try: return sol(item) except (NotImplementedError, TypeError, ValueError): pass # At this point, we're not really sure what type the object is. Raise a warning and return the item, then hope for the best. import warnings warnings.warn(f"In solution substitution, could not convert an object of type {t}.\n" f"Returning it and hoping for the best.", UserWarning) return item if inplace: convert(self) else: return convert(self)
[docs]def load( filename: Union[str, Path], verbose: bool = True, ) -> AeroSandboxObject: """ Loads an AeroSandboxObject from a file. Upon load, will compare metadata from the file to the current Python version and AeroSandbox version. If there are any discrepancies, will raise a warning. Args: filename: The filename to load from. Should be a .asb file. verbose: If True, prints messages to console on successful load. Returns: An AeroSandboxObject. """ filename = Path(filename) # Load the object from file with open(filename, "rb") as f: obj = dill.load(f) # At this point, the object is loaded try: metadata = obj._asb_metadata except AttributeError: warnings.warn( "This object was saved without metadata. This may cause compatibility issues.", stacklevel=2 ) return obj # Check if the Python version is different try: saved_python_version = metadata["python_version"] current_python_version = ".".join([ str(sys.version_info.major), str(sys.version_info.minor), str(sys.version_info.micro), ]) saved_python_version_split = saved_python_version.split(".") current_python_version_split = current_python_version.split(".") if any([ saved_python_version_split[0] != current_python_version_split[0], saved_python_version_split[1] != current_python_version_split[1], ]): warnings.warn( f"This object was saved with Python {saved_python_version}, but you are currently using Python {current_python_version}.\n" f"This may cause compatibility issues.", stacklevel=2, ) except KeyError: warnings.warn( "This object was saved without Python version info metadata. This may cause compatibility issues.", stacklevel=2 ) # Check if the AeroSandbox version is different import aerosandbox as asb try: saved_asb_version = metadata["asb_version"] if saved_asb_version != asb.__version__: warnings.warn( f"This object was saved with AeroSandbox {saved_asb_version}, but you are currently using AeroSandbox {asb.__version__}.\n" f"This may cause compatibility issues.", stacklevel=2, ) except KeyError: warnings.warn( "This object was saved without AeroSandbox version info metadata. This may cause compatibility issues.", stacklevel=2 ) if verbose: print(f"Loaded {str(obj)} from:\n\t{filename}") return obj
[docs]class ExplicitAnalysis(AeroSandboxObject):
[docs] default_analysis_specific_options: Dict[type, Dict[str, Any]] = {}
"""This is part of AeroSandbox's "analysis-specific options" feature, which lets you "tag" geometry objects with flags that change how different analyses act on them. This variable, `default_analysis_specific_options`, allows you to specify default values for options that can be used for specific problems. This should be a dictionary, where: * keys are the geometry-like types that you might be interested in defining parameters for. * values are dictionaries, where: * keys are strings that label a given option * values are anything. These are used as the default values, in the event that the associated geometry doesn't override those. An example of what this variable might look like, for a vortex-lattice method aerodynamic analysis: >>> default_analysis_specific_options = { >>> Airplane: dict( >>> profile_drag_coefficient=0 >>> ), >>> Wing : dict( >>> wing_level_spanwise_spacing=True, >>> spanwise_resolution=12, >>> spanwise_spacing="cosine", >>> chordwise_resolution=12, >>> chordwise_spacing="cosine", >>> component=None, # type: int >>> no_wake=False, >>> no_alpha_beta=False, >>> no_load=False, >>> drag_polar=dict( >>> CL1=0, >>> CD1=0, >>> CL2=0, >>> CD2=0, >>> CL3=0, >>> CD3=0, >>> ), >>> ) >>> } """
[docs] def get_options(self, geometry_object: AeroSandboxObject, ) -> Dict[str, Any]: """ Retrieves the analysis-specific options that correspond to both: * An analysis type (which is this object, "self"), and * A specific geometry object, such as an Airplane or Wing. Args: geometry_object: An instance of an AeroSandbox geometry object, such as an Airplane, Wing, etc. * In order for this function to do something useful, you probably want this option to have `analysis_specific_options` defined. See the asb.Airplane constructor for an example of this. Returns: A dictionary that combines: * This analysis's default options for this geometry, if any exist. * The geometry's declared analysis-specific-options for this analysis, if it exists. These geometry options will override the defaults from the analysis. This dictionary has the format: * keys are strings, listing specific options * values can be any type, and simply state the value of the analysis-specific option following the logic above. Note: if this analysis defines a set of default options for the geometry type in question (by using `self.default_analysis_specific_options`), all keys from the geometry object's `analysis_specific_options` will be validated against those in the default options set. A warning will be raised if keys do not correspond to those in the defaults, as this (potentially) indicates a typo, which would otherwise be difficult to debug. """ ### Determine the types of both this analysis and the geometry object. analysis_type: type = self.__class__ geometry_type: type = geometry_object.__class__ ### Determine whether this analysis and the geometry object have options that specifically reference each other or not. try: analysis_options_for_this_geometry = self.default_analysis_specific_options[geometry_type] assert hasattr(analysis_options_for_this_geometry, "items") except (AttributeError, KeyError, AssertionError): analysis_options_for_this_geometry = None try: geometry_options_for_this_analysis = geometry_object.analysis_specific_options[analysis_type] assert hasattr(geometry_options_for_this_analysis, "items") except (AttributeError, KeyError, AssertionError): geometry_options_for_this_analysis = None ### Now, merge those options (with logic depending on whether they exist or not) if analysis_options_for_this_geometry is not None: options = copy.deepcopy(analysis_options_for_this_geometry) if geometry_options_for_this_analysis is not None: for k, v in geometry_options_for_this_analysis.items(): if k in analysis_options_for_this_geometry.keys(): options[k] = v else: import warnings allowable_keys = [f'"{k}"' for k in analysis_options_for_this_geometry.keys()] warnings.warn( f"\nAn object of type '{geometry_type.__name__}' declared the analysis-specific option '{k}' for use with analysis '{analysis_type.__name__}'.\n" f"This was unexpected! Allowable analysis-specific options for '{geometry_type.__name__}' with '{analysis_type.__name__}' are:\n" "\t" + "\n\t".join(allowable_keys) + "\n" "Did you make a typo?", stacklevel=2, ) else: if geometry_options_for_this_analysis is not None: options = geometry_options_for_this_analysis else: options = {} return options
[docs]class ImplicitAnalysis(AeroSandboxObject): @staticmethod
[docs] def initialize(init_method): """ A decorator that should be applied to the __init__ method of ImplicitAnalysis or any subclass of it. Usage example: >>> class MyAnalysis(ImplicitAnalysis): >>> >>> @ImplicitAnalysis.initialize >>> def __init__(self): >>> self.a = self.opti.variable(init_guess = 1) >>> self.b = self.opti.variable(init_guess = 2) >>> >>> self.opti.subject_to( >>> self.a == self.b ** 2 >>> ) # Add a nonlinear governing equation Functionality: The basic purpose of this wrapper is to ensure that every ImplicitAnalysis has an `opti` property that points to an optimization environment (asb.Opti type) that it can work in. How do we obtain an asb.Opti environment to work in? Well, this decorator adds an optional `opti` parameter to the __init__ method that it is applied to. 1. If this `opti` parameter is not provided, then a new empty `asb.Opti` environment is created and stored as `ImplicitAnalysis.opti`. 2. If the `opti` parameter is provided, then we simply assign the given `asb.Opti` environment (which may already contain other variables/constraints/objective) to `ImplicitAnalysis.opti`. In addition, a property called `ImplicitAnalysis.opti_provided` is stored, which records whether the user provided an Opti environment or if one was instead created for them. If the user did not provide an Opti environment (Option 1 from our list above), we assume that the user basically just wants to perform a normal, single-disciplinary analysis. So, in this case, we proceed to solve the analysis as-is and do an in-place substitution of the solution. If the user did provide an Opti environment (Option 2 from our list above), we assume that the user might potentially want to add other implicit analyses to the problem. So, in this case, we don't solve the analysis, and the user must later solve the analysis by calling `sol = opti.solve()` or similar. """ def init_wrapped(self, *args, opti=None, **kwargs): if opti is None: self.opti = Opti() self.opti_provided = False else: self.opti = opti self.opti_provided = True init_method(self, *args, **kwargs) if not self.opti_provided and not self.opti.x.shape == (0, 1): sol = self.opti.solve() self.__dict__ = sol(self.__dict__) return init_wrapped
class ImplicitAnalysisInitError(Exception): def __init__(self, message=""" Your ImplicitAnalysis object doesn't have an `opti` property! This is almost certainly because you didn't decorate your object's __init__ method with `@ImplicitAnalysis.initialize`, which you should go do. """ ): self.message = message super().__init__(self.message) @property
[docs] def opti(self): try: return self._opti except AttributeError: raise self.ImplicitAnalysisInitError()
@opti.setter def opti(self, value: Opti): self._opti = value @property
[docs] def opti_provided(self): try: return self._opti_provided except AttributeError: raise self.ImplicitAnalysisInitError()
@opti_provided.setter def opti_provided(self, value: bool): self._opti_provided = value