diff --git a/examples/01_Simulation.ipynb b/examples/01_Simulation.ipynb index 11688e5..03ba6d6 100644 --- a/examples/01_Simulation.ipynb +++ b/examples/01_Simulation.ipynb @@ -342,6 +342,527 @@ "## Future Loading" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The previous examples feature a simple ThrownObject model, which does not have any inputs. Unlike ThrownObject, most prognostics models have some sort of [input](https://nasa.github.io/progpy/glossary.html#term-input). The input is some sort of control or loading applied to the system being modeled. In this section we will describe how to simulate a model which features an input.\n", + "\n", + "In this example we will be using the BatteryCircuit model from the models subpackage (see 3. Included Models). This is a simple battery discharge model where the battery is represented by an equivilant circuit.\n", + "\n", + "Like the past examples, we start by importing and creating the model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from prog_models.models import BatteryCircuit\n", + "m = BatteryCircuit()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can see the batteries inputs, states, and outputs (described above) by accessing these attributes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('outputs:', m.outputs)\n", + "print('inputs:', m.inputs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Consulting the [model documentation](https://nasa.github.io/progpy/api_ref/prog_models/IncludedModels.html#prog_models.models.BatteryCircuit), I see that the outputs (i.e., measurable values) of the model are temperature (`t`) and voltage (`v`). The model's input is the current (`i`) drawn from the model.\n", + "\n", + "If we try to simulate as we do above, it wouldn't work because the battery discharge is a function of the current (`i`) drawn from the battery. Simulation for a model like this requires that we define the future load. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Piecewise load\n", + "\n", + "For the first example, we define a piecewise loading profile using the `progpy.loading.Piecewise` class. This is one of the most common loading profiles. First we import the class from the loading subpackage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from progpy.loading import Piecewise" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we define a loading profile. Piecewise loader takes 3 arguments: 1. the model InputContainer, 2. times and 3. loads, each of these are explained in more detail below.\n", + "\n", + "The model input container is a class for representing the input for a model. It's a class attribute for every model, and is specific to that model. It can be found at m.InputContainer\n", + "\n", + "Times are the ending time for each load. For example, if times were [5, 7, 10], then the first load would apply until t=5, then the second load would apply for 2 seconds, following by the third load for 5 more seconds. \n", + "\n", + "Loads are a dictionary of arrays, where the keys of the dictionary are the inputs to the model (for a battery, just current `i`), and the values in the array are the value at each time in times. If the loads array is one longer than times, then the last value is the \"default load\", i.e., the load that will be applied after the last time has passed.\n", + "\n", + "For example, we might define this load profile for our battery." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loading = Piecewise(\n", + " InputContainer=m.InputContainer,\n", + " times=[600, 900, 1800, 3000],\n", + " values={'i': [2, 1, 4, 2, 3]})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this case, the current drawn (`i`) is 2 amps until t is 600 seconds, then it is 1 for the next 300 seconds (until 900 seconds), etc. The \"default load\" is 3, meaning that after the last time has passed (3000 seconds) a current of 3 will be drawn. \n", + "\n", + "Now that we have this load profile, lets run a simulation with our model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's take a look at the inputs to the model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.inputs.plot(ylabel=\"Current Draw (amps)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See above that the load profile is piecewise, matching the profile we defined above.\n", + "\n", + "Plotting the outputs, you can see jumps in the voltage levels as the current changes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.outputs.plot(compact=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this example we simulated to threshold, loading the system using a simple piecewise load profile. This is the most common load profile and will probably work for most cases " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Moving Average" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Another common loading scheme is the moving-average load. This loading scheme assumes that the load will continue like it's seen in the past. This is useful when you dont know the exact load, but you expect it to be consistant.\n", + "\n", + "Like with Piecewise loading, the first step it to import the loading class. In this case, `progpy.loading.MovingAverage`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from progpy.loading import MovingAverage" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next we create the moving average loading object, passing in the InputContainer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loading = MovingAverage(m.InputContainer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The moving average load estimator requires an additional step, sending the observed load. This is done using the add_load method. Let's load it up with some observed current draws. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "measured_loads = [4, 4.5, 4.0, 4, 2.1, 1.8, 1.99, 2.0, 2.01, 1.89, 1.92, 2.01, 2.1, 2.2]\n", + " \n", + "for load in measured_loads:\n", + " loading.add_load({'i': load})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In practice the add_load method should be called whenever there's new input (i.e., load) information. The MovingAverage load estimator averages over a window of elemetns, configurable at construction using the window argument (e.g., MovingAverage(m.InputContainer, window=12))\n", + "\n", + "Now the configured load estimator can be used in simulation. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's take a look at the resulting input current." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.inputs.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the loading is a constant around 2, this is because the larger loads (~4 amps) are outside of the averaging window. Here are the resulting outputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.outputs.plot(compact=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the voltage and temperature curves are much cleaner. They dont have the jumps present in the piecewise loading example. This is due to the constant loading.\n", + "\n", + "In this example we simulated to threshold, loading the system using a constant load profile caluclated using the moving average load estimator. This load estimator needs to be updated with the add_load method whenever new loading data is available. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Gaussian Noise in Loading" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Typically, users have an idea of what loading will look like, but there is some uncertainty. Future load extimates are hardly ever known exactly. This is where load wrappers like the `progpy.loading.GaussianNoiseLoadWrapper` come into play. The GaussianNoiseLoadWrapper wraps around another load profile, adding a random amount of noise, sampled from a gaussian distribution, at each step. In simulation this will show some variability, but this becomes more important in prediction (see 9. Prediction).\n", + "\n", + "In this example we will repeat the Piecewise load example, this time using the GaussianNoiseLoadWrapper to represent our uncertainty in our future load estimate. \n", + "\n", + "First we will import the necessary classes and construct the Piecewise load estimation just as in the previous example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from progpy.loading import Piecewise, GaussianNoiseLoadWrapper\n", + "loading = Piecewise(\n", + " InputContainer=m.InputContainer,\n", + " times=[600, 900, 1800, 3000],\n", + " values={'i': [2, 1, 4, 2, 3]})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next we will wrap this loading object in our Gaussian noise load wrapper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loading = GaussianNoiseLoadWrapper(loading, 0.2)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this case we're adding guassian noise with a standard deviation of 0.2 to the result of the previous load estimator.\n", + "\n", + "Now let's simulate and look at the input profile." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)\n", + "fig = results.inputs.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note the loading profile follows the piecewise shape, but with noise. If you run it again, you would get a slightly different result." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)\n", + "fig = results.inputs.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here are the corresponding outputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.outputs.plot(compact=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the noise in input can be seen in the resulting output plots.\n", + "\n", + "In this section we introduced the concept of NoiseWrappers and how they are used to represent uncertainty in future loading. This concept is expecially important when used with prediction (see 9. Prediction). A GuassianNoiseLoadWrapper was used with a Piecewise loading profile to demonstrate it, but NoiseWrappers can be applied to any loading object or function, including the advanced profiles introduced in the next section." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Custom load profiles" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For most applications, the standard load estimation classes can be used to represent a users uexpectation of future loading. However, there are some cases where load is some complex combination of time and state that cannot be represented by these classes. This section briefly describes a few of these cases. \n", + "\n", + "The first example is similar to the last one, in that there is gaussian noise added to some underlying load profile. In this case the magnitude of noise increases linerally with time. This is an important example, as it allows us to represent a case where loading further out in time has more uncertainty (i.e., is less well known). This is common for many prognostic usecases.\n", + "\n", + "Custom load profiles can be represented either as a function (t, x=None) -> u, where t is time, x is state, and u is input. or as a class which implements the __call__ method with the same profile as the function.\n", + "\n", + "In this case we will use the first method (i.e., the function). We will define a function that will use a defined slope (derivitive of standard deviation with time)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from numpy.random import normal\n", + "base_load = 2 # Base load (amps)\n", + "std_slope = 1e-4 # Derivative of standard deviation with time\n", + "def loading(t, x=None):\n", + " std = std_slope * t\n", + " return m.InputContainer({'i': normal(base_load, std)})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the above code is specifically for a battery, but it could be generalized to any system.\n", + "\n", + "Now let's simulate and look at the input profile." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)\n", + "fig = results.inputs.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note how the noise in the input signal increases with time. Since this is a random process, if you were to run this again you would get a different result.\n", + "\n", + "Here is the corresponding output. Note you can see the effects of the increasingly erradic input in the voltage curve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.outputs.plot(compact=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the final example we will define a loading profile that considers state. In this example, we're simulating a scenario where loads are removed (i.e., turned off) when discharge event state (i.e., SOC) reaches 0.25. This emulates a \"low power mode\" often employed in battery-powered electronics.\n", + "\n", + "For simplicity the underlying load will be constant, but this same approach could be applied to more complex profiles, and noise can be added on top using a NoiseWrapper." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "normal_load = m.InputContainer({'i': 2.7})\n", + "low_power_load = m.InputContainer({'i': 1.9})\n", + "\n", + "def loading(t, x=None):\n", + " if x is not None:\n", + " # State is provided\n", + " soc = m.event_state(x)['EOD']\n", + " return normal_load if soc > 0.25 else low_power_load\n", + " return normal_load" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the above example checks if x is not None. For some models, for the first timestep, state may be None (because state hasn't yet been calculated).\n", + "\n", + "Now let's use this in simulation and take a look at the loading profile." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = m.simulate_to_threshold(loading, save_freq=100)\n", + "fig = results.inputs.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here, as expected, load is at normal level for most of the time, then falls to low power mode towards the end of discharge.\n", + "\n", + "Let's look at the corresponding outputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig = results.outputs.plot(compact=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice the jump in voltage at the point where the load changed. Low power mode extended the life of the battery.\n", + "\n", + "In this section we show how to make custom loading profiles. Most applications can use the standard load classes, but some may require creating complex custom load profiles using this feature." + ] + }, { "cell_type": "markdown", "metadata": {},