Defining Fault Sampling Approaches in fmdtools

Fault Sampling is used to evaluate the overall resilience of a system to a set of faults and the corresponding risks associated with these faults. There is no single best way to define the set of scenarios to evaluate resilience with, because a given resilience analysis may need more or less detail, support more or less computational time, or be interested in specific scenario types of interest.

Thus, there are a number of use-cases supported by fmdtools for different sampling models. This document will demonstrate and showcase a few of them.

Copyright © 2024, United States Government, as represented by the Administrator of the National Aeronautics and Space Administration. All rights reserved.

The “"Fault Model Design tools - fmdtools version 2"” software is licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
[1]:
from fmdtools.sim.sample import SampleApproach, FaultSample, FaultDomain
from fmdtools.analyze.tabulate import FMEA, Comparison
import fmdtools.sim.propagate as prop
import numpy as np

Basics

Fault sampling involves: - Defining faults and fault models for each function/component/flows of the model is done using the ‘Mode’ Class - Defining a fault sampling approach, using the: - SampleApproach class, or - FaultDomain and FaultSample calsses directly - Propagating faults through the model (using the propagate.faultsample method) Before proceeding, it can be helpful to look through their respective documentation.

Model Setup

Consider the rover model in rover_model

[2]:
import inspect
from rover_model import Power, PowerMode

This rover has a Power function:

[3]:
print(inspect.getsource(Power))
class Power(Function):
    """Rover power supply."""

    __slots__ = ("ee_15", "ee_5", "ee_12", "switch")
    container_s = PowerState
    container_m = PowerMode
    flow_ee_15 = EE
    flow_ee_5 = EE
    flow_ee_12 = EE
    flow_switch = Switch

    def static_behavior(self, time):
        """Determine power use based on mode."""
        if self.m.in_mode("off"):
            self.off_power()
        elif self.m.in_mode("supply"):
            self.supply_power()
        elif self.m.in_mode("short"):
            self.short_power()
        elif self.m.in_mode("no_charge"):
            self.no_charge_power()

        if self.m.in_mode("charge"):
            self.charge_power_usage()
        else:
            self.power_usage()
            if self.m.in_mode("short"):
                self.short_power_usage()

    def dynamic_behavior(self, time):
        """Charge increment over time."""
        self.s.inc(charge=-self.s.power / 100)
        self.s.limit(charge=(0, 100))

    def short_power(self):
        """Power in case of a short has normal voltage."""
        self.ee_5.s.v = 5
        self.ee_12.s.v = 12
        self.ee_15.s.v = 15

    def no_charge_power(self):
        """Battery is out of charge."""
        self.ee_5.s.v = 0
        self.ee_12.s.v = 0
        self.ee_15.s.v = 0

    def off_power(self):
        """Power supply is shut off."""
        self.ee_5.s.put(v=0, a=0)
        self.ee_12.s.put(v=0, a=0)
        self.ee_15.s.put(v=0, a=0)
        if self.switch.s.power:
            self.m.set_mode("supply")

    def supply_power(self):
        """Power supply is in supply mode."""
        if self.s.charge > 0:
            self.ee_5.s.v = 5
            self.ee_12.s.v = 12
            self.ee_15.s.v = 15
        else:
            self.m.set_mode("no_charge")
        if not self.switch.s.power:
            self.m.set_mode("off")

    def power_usage(self):
        """Calculate the power usage in general."""
        self.s.power = (1.0 + self.ee_12.s.mul("v", "a") +
                        self.ee_5.s.mul("v", "a") + self.ee_15.s.mul("v", "a"))

    def charge_power_usage(self):
        """Calculate the power usage when the battery charges."""
        self.s.power = -1
        if self.s.charge == 100:
            self.m.set_mode("off")

    def short_power_usage(self):
        """Calculate power usage when there is a short (calculated as double)."""
        self.s.power = self.s.power * 2
        if self.s.charge == 0:
            self.m.set_mode("no_charge")
        if not self.switch.s.power:
            self.m.set_mode("off")

Which contains the mode PowerMode:

[4]:
print(inspect.getsource(PowerMode))
class PowerMode(Mode):
    """
    Possible modes for Power function.

    Modes
    -------
    no_charge : Fault
        Battery is out of charge.
    short: Fault
        There is a short.
    supply: Mode
        supply power
    charge: Mode
        charge battery
    standby: Mode
        power supply is in stand by
    off: Mode
        power supply is off
    """

    fm_args = {"no_charge": (1e-5, 100, {"off": 1.0}),
               "short": (1e-5, 100, {"supply": 1.0})}
    opermodes = ("supply", "charge", "off")
    mode: str = "off"
    exclusive = True

The class variable fm_args specifies that there are two possible modes to inject, “no_charge”, and “shortn”, with no more information given for each mode. More information has been added in the tuples of the dictionary, including: - rate - repair cost - phase dictionary The phase dictionary is important because it specifies that this mode is to occur in a given phase. In this case, no_charge is supposed to only occur during the standby phase while short is only supposed to occur during the supply phase. In this Mode, these phases correspond to the operational modes (opermodes), but they may correspond to other operational modes also.

All of these fields are optional, but they do help us develop a more informed statistical sample of the fault modes.

Setting up a FaultSample

Sampling using FaultSample first requires creating a FaultDomain to sample from. These faultdomains can be created from both models and individual functions:

[5]:
fd_power = FaultDomain(Power())
fd_power.add_all()
fd_power
[5]:
FaultDomain with faults:
 -('power', 'no_charge')
 -('power', 'short')
[6]:
from rover_model import Rover
fd_rvr = FaultDomain(Rover())
fd_rvr.add_all()
fd_rvr
[6]:
FaultDomain with faults:
 -('power', 'no_charge')
 -('power', 'short')
 -('perception', 'bad_feed')
 -('plan_path', 'no_con')
 -('plan_path', 'crash')
 -('drive', 'hmode_0')
 -('drive', 'hmode_1')
 -('drive', 'hmode_2')
 -('drive', 'hmode_3')
 -('drive', 'hmode_4')
 -...more

Note that there are several methods in FaultDomain which let us specify the list of faults we want to sample from, e.g.:

[7]:
fd_short = FaultDomain(Rover())
fd_short.add_all_modes("short")
fd_short
[7]:
FaultDomain with faults:
 -('power', 'short')

or:

[8]:
fd_pwr = FaultDomain(Rover())
fd_pwr.add_all_fxn_modes("power")
fd_pwr
[8]:
FaultDomain with faults:
 -('power', 'no_charge')
 -('power', 'short')

We can then sample this domain using a FaultSample:

[9]:
fs_pwr = FaultSample(fd_pwr, def_mdl_phasemap=False)

Note that FaultSamples have two main variables: faultdomain and phasemap. A PhaseMap is essentially a dictionary of phases to sample from.

In the above case, we don’t want to use phase information to form the sample, so we don’t provide any and we set def_mdl_phasemap=False, since this would get phase information from the model.

To add scenarios to the FaultSample, we can then use one of the add methods:

[10]:
fs_pwr.add_fault_times([2,5,10])
fs_pwr
[10]:
FaultSample of scenarios:
 - power_no_charge_t2
 - power_no_charge_t5
 - power_no_charge_t10
 - power_short_t2
 - power_short_t5
 - power_short_t10

As shown, this adds the list of faults in the faultdomain over the given times.

Note the underlying rate information in these scenarios is all the same:

[11]:
fs_pwr.scenarios()
[11]:
[SingleFaultScenario(sequence={2.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(2,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t2', time=2, phase=''),
 SingleFaultScenario(sequence={5.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(5,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t5', time=5, phase=''),
 SingleFaultScenario(sequence={10.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(10,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t10', time=10, phase=''),
 SingleFaultScenario(sequence={2.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(2,), function='power', fault='short', rate=1e-05, name='power_short_t2', time=2, phase=''),
 SingleFaultScenario(sequence={5.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(5,), function='power', fault='short', rate=1e-05, name='power_short_t5', time=5, phase=''),
 SingleFaultScenario(sequence={10.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(10,), function='power', fault='short', rate=1e-05, name='power_short_t10', time=10, phase='')]

But we know better than this–some of the faults should have zero rate if they are going to occur in phases they don’t apply to!

If we want to sample the given phases for the sample, we can additionally pass a phasemap generated by first running the model in the nominal state:

[12]:
res, hist = prop.nominal(Rover())

We can then get phase information from this history using the fmdtools.analyze.phases.from_hist:

[13]:
from fmdtools.analyze.phases import from_hist, phaseplot
phases = from_hist(hist)
phases
[13]:
{'power': PhaseMap({'off': [0.0, 0.0], 'supply': [1.0, 112.0]}, {'off': {'off'}, 'supply': {'supply'}}),
 'perception': PhaseMap({'off': [0.0, 1.0], 'feed': [2.0, 112.0]}, {'off': {'off'}, 'feed': {'feed'}}),
 'plan_path': PhaseMap({'standby': [0.0, 4.0], 'drive': [5.0, 112.0]}, {'standby': {'standby'}, 'drive': {'drive'}}),
 'override': PhaseMap({'off': [0.0, 1.0], 'standby': [2.0, 112.0]}, {'off': {'off'}, 'standby': {'standby'}})}

Which can be visualized using:

[14]:
fig = phaseplot(phases)
../../_images/examples_rover_FaultSample_Use-Cases_30_0.png

The PhaseMap for Power is here in power:

[15]:
phases['power']
[15]:
PhaseMap({'off': [0.0, 0.0], 'supply': [1.0, 112.0]}, {'off': {'off'}, 'supply': {'supply'}})

Which we can use to create a FaultSample which only samples the phases corresponding to the information given in Mode:

[16]:
fs_pwr = FaultSample(fd_pwr, phasemap=phases['power'])
fs_pwr.add_fault_phases()
fs_pwr
[16]:
FaultSample of scenarios:
 - power_no_charge_t0p0
 - power_short_t0p0
 - power_no_charge_t56p0
 - power_short_t56p0

If we look at the rate information, however:

[17]:
fs_pwr.scenarios()
[17]:
[SingleFaultScenario(sequence={0.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(0.0,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t0p0', time=0.0, phase='off'),
 SingleFaultScenario(sequence={0.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(0.0,), function='power', fault='short', rate=0.0, name='power_short_t0p0', time=0.0, phase='off'),
 SingleFaultScenario(sequence={56.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(56.0,), function='power', fault='no_charge', rate=0.0, name='power_no_charge_t56p0', time=56.0, phase='supply'),
 SingleFaultScenario(sequence={56.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(56.0,), function='power', fault='short', rate=1e-05, name='power_short_t56p0', time=56.0, phase='supply')]

The rate for scenarios outside the phases is zero!

We can remove these scenarios using FaultSample.prune_scenarios:

[18]:
fs_pwr.prune_scenarios("rate", np.greater, 0.0)
fs_pwr
[18]:
FaultSample of scenarios:
 - power_no_charge_t0p0
 - power_short_t56p0
[19]:
fs_pwr.scenarios()
[19]:
[SingleFaultScenario(sequence={0.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(0.0,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t0p0', time=0.0, phase='off'),
 SingleFaultScenario(sequence={56.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(56.0,), function='power', fault='short', rate=1e-05, name='power_short_t56p0', time=56.0, phase='supply')]

As shown, now the only scenarios in the FaultSample are ones which have nonzero rate.

[20]:
assert all([scen.rate > 0 for scen in fs_pwr.scenarios()])

To enable multiple samples to be generated for different faultdomains accross the model, we can use SampleApproach, e.g.:

[21]:
phases
[21]:
{'power': PhaseMap({'off': [0.0, 0.0], 'supply': [1.0, 112.0]}, {'off': {'off'}, 'supply': {'supply'}}),
 'perception': PhaseMap({'off': [0.0, 1.0], 'feed': [2.0, 112.0]}, {'off': {'off'}, 'feed': {'feed'}}),
 'plan_path': PhaseMap({'standby': [0.0, 4.0], 'drive': [5.0, 112.0]}, {'standby': {'standby'}, 'drive': {'drive'}}),
 'override': PhaseMap({'off': [0.0, 1.0], 'standby': [2.0, 112.0]}, {'off': {'off'}, 'standby': {'standby'}})}
[22]:
sa = SampleApproach(Rover(), phasemaps=phases)
# adding fault domains
sa.add_faultdomain("drive", "fault", "drive", "hmode_1")
sa.add_faultdomain("plan_path", "all_fxn_modes", "plan_path")
sa.add_faultdomain("power", "all_fxn_modes", "power")
sa.add_faultsample("drive", "fault_phases", "drive", phasemap="plan_path")
sa.add_faultsample("plan_path", "fault_phases", "plan_path", phasemap="plan_path")
sa.add_faultsample("power", "fault_phases", "power", phasemap="power")
sa.prune_scenarios()
[23]:
sa
[23]:
SampleApproach for rover with:
 faultdomains: drive, plan_path, power
 faultsamples: drive, plan_path, power
[24]:
sa.scenarios()
[24]:
[SingleFaultScenario(sequence={58.0: Injection(faults={'drive': ['hmode_1']}, disturbances={})}, times=(58.0,), function='drive', fault='hmode_1', rate=0.02857142857142857, name='drive_hmode_1_t58p0', time=58.0, phase='drive'),
 SingleFaultScenario(sequence={2.0: Injection(faults={'plan_path': ['no_con']}, disturbances={})}, times=(2.0,), function='plan_path', fault='no_con', rate=0.0001, name='plan_path_no_con_t2p0', time=2.0, phase='standby'),
 SingleFaultScenario(sequence={2.0: Injection(faults={'plan_path': ['crash']}, disturbances={})}, times=(2.0,), function='plan_path', fault='crash', rate=0.0001, name='plan_path_crash_t2p0', time=2.0, phase='standby'),
 SingleFaultScenario(sequence={58.0: Injection(faults={'plan_path': ['no_con']}, disturbances={})}, times=(58.0,), function='plan_path', fault='no_con', rate=0.0001, name='plan_path_no_con_t58p0', time=58.0, phase='drive'),
 SingleFaultScenario(sequence={58.0: Injection(faults={'plan_path': ['crash']}, disturbances={})}, times=(58.0,), function='plan_path', fault='crash', rate=0.0001, name='plan_path_crash_t58p0', time=58.0, phase='drive'),
 SingleFaultScenario(sequence={0.0: Injection(faults={'power': ['no_charge']}, disturbances={})}, times=(0.0,), function='power', fault='no_charge', rate=1e-05, name='power_no_charge_t0p0', time=0.0, phase='off'),
 SingleFaultScenario(sequence={56.0: Injection(faults={'power': ['short']}, disturbances={})}, times=(56.0,), function='power', fault='short', rate=1e-05, name='power_short_t56p0', time=56.0, phase='supply')]

This is mostly useful when we would like to sample different functions in a model differently than others (e.g., using different phases), but still want to propagate the scenarios together as a part of a single sample.

Propagating Faults

Given the FaultSample approach, faults can then be propagated through the model to get results. Note that these faults can be sampled in parallel if desired using a user-provided pool (see the parallel pool tutorial in the \pump example folder).

[25]:
res, hist = prop.fault_sample(Rover(), fs_pwr)
SCENARIOS COMPLETE: 100%|██████████| 2/2 [00:00<00:00,  8.89it/s]
[26]:
res
[26]:
power_no_charge_t0p0.endclass.rate: 1e-05
power_no_charge_t0p0.endclass.cost:    0
power_no_charge_t0p0.endclass.prob:  1.0
power_no_charge_t0p0                   0
power_no_charge_t0p0.endclass.in_bound: True
power_no_charge_t0p0               False
power_no_charge_t0p0                   1
power_no_charge_t0p0                   1
power_no_charge_t0p0.endclass.end_dist: 28.91724385770989
power_no_charge_t0p0                 0.0
power_no_charge_t0p0.endclass.faults: {'power': ['no_charge']}
power_no_charge_t0p0incomplete mission faulty
power_no_charge_t0p0.endclass.end_x: 0.0
power_no_charge_t0p0.endclass.end_y: 0.0
power_no_charge_t0p0.endclass.endpt: array(2)
power_short_t56p0.endclass.rate:   1e-05
power_short_t56p0.endclass.cost:       0
power_short_t56p0.endclass.prob:     1.0
power_short_t56p0.en                   0
power_short_t56p0.endclass.in_bound: True
power_short_t56p0.endclass.at_finish: True
power_short_t56p0.endclass.line_dist:  1
power_short_t56p0.endclass.num_modes:  1
power_short_t56p0.endclass.end_dist: 0.0
power_short_t56p0.en 0.11859644458085598
power_short_t56p0.endclass.faults: {'power': ['short']}
power_short_t56p0.en              faulty
power_short_t56p0.endclass.end_x: 29.254775331608617
power_short_t56p0.endclass.end_y: -0.7827640334587979
power_short_t56p0.endclass.endpt: array(2)
nominal.endclass.rate:               1.0
nominal.endclass.cost:                 0
nominal.endclass.prob:               1.0
nominal.endclass.expected_cost:        0
nominal.endclass.in_bound:          True
nominal.endclass.at_finish:         True
nominal.endclass.line_dist:            1
nominal.endclass.num_modes:            0
nominal.endclass.end_dist:           0.0
nominal.endclass.tot_deviation: 0.11859644458085598
nominal.endclass.faults:              {}
nominal.endclass.classification: nominal mission
nominal.endclass.end_x: 29.254775331608617
nominal.endclass.end_y: -0.7827640334587979
nominal.endclass.endpt:         array(2)

These responses can be visualized over the given faults:

[27]:
fmea = FMEA(res, fs_pwr, metrics = ["end_dist", "line_dist", "tot_deviation"])
fmea.as_table()
[27]:
end_dist line_dist tot_deviation
power short 0.000000 1 0.118596
no_charge 28.917244 1 0.000000
[28]:
fmea.as_plots(cols=2)
[28]:
(<Figure size 600x400 with 4 Axes>,
 array([<Axes: title={'center': 'end_dist'}>,
        <Axes: title={'center': 'line_dist'}, xlabel="['function', 'fault']">,
        <Axes: title={'center': 'tot_deviation'}, xlabel="['function', 'fault']">,
        <Axes: >], dtype=object))
../../_images/examples_rover_FaultSample_Use-Cases_53_1.png

Or over time/any other variable:

[29]:
comp = Comparison(res, fs_pwr, metrics = ["end_dist", "line_dist", "tot_deviation"], factors =['time'])
comp.as_table()
[29]:
end_dist line_dist tot_deviation
56.0 0.000000 0.00001 0.000001
0.0 0.000289 0.00001 0.000000
[30]:
comp.as_plots(cols=2)
[30]:
(<Figure size 600x400 with 4 Axes>,
 array([<Axes: title={'center': 'end_dist'}>,
        <Axes: title={'center': 'line_dist'}, xlabel='time'>,
        <Axes: title={'center': 'tot_deviation'}, xlabel='time'>, <Axes: >],
       dtype=object))
../../_images/examples_rover_FaultSample_Use-Cases_56_1.png
[ ]:

[ ]:

[ ]: