|
| 1 | +import { |
| 2 | + ConsumerSubject, EventBus, MappedSubject, Subject, Subscribable, SubscribableMapFunctions, SubscribableUtils, |
| 3 | + Subscription, UserSettingManager, Value |
| 4 | +} from '@microsoft/msfs-sdk'; |
| 5 | + |
| 6 | +import { AdcSystemEvents } from '../../system/AdcSystem'; |
| 7 | +import { BaroTransitionAlertUserSettingTypes } from '../../settings/BaroTransitionAlertUserSettings'; |
| 8 | +import { BaroTransitionAlertEvents } from './BaroTransitionAlertEvents'; |
| 9 | + |
| 10 | +/** |
| 11 | + * Configuration options for {@link BaroTransitionAlertManager}. |
| 12 | + */ |
| 13 | +export type BaroTransitionAlertManagerOptions = { |
| 14 | + /** |
| 15 | + * The ID to assign the manager. Event bus topics published by the manager will be suffixed with its ID. Cannot be |
| 16 | + * the empty string. |
| 17 | + */ |
| 18 | + id: string; |
| 19 | + |
| 20 | + /** The index of the ADC from which to source altitude and barometric setting data. */ |
| 21 | + adcIndex: number | Subscribable<number>; |
| 22 | +}; |
| 23 | + |
| 24 | +/** |
| 25 | + * Barometric transition alert states. |
| 26 | + */ |
| 27 | +enum AlertState { |
| 28 | + Off = 'Off', |
| 29 | + Unarmed = 'Unarmed', |
| 30 | + Armed = 'Armed', |
| 31 | + Active = 'Active', |
| 32 | + ActiveLocked = 'ActiveLocked', |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * A manager that controls the state of a set of barometric transition alerts. Each manager controls a transition |
| 37 | + * altitude alert, which is triggered when climbing through a transition altitude without changing an altimeter's |
| 38 | + * barometric setting to standard, and a transition level alert, which is triggered when descending through a |
| 39 | + * transition level without changing an altimeter's barometric setting out of standard. The states of the alerts are |
| 40 | + * published to the topics defined in {@link BaroTransitionAlertEvents}. |
| 41 | + * |
| 42 | + * The manager requires that the topics defined in {@link AdcSystemEvents} are published to the event bus. |
| 43 | + */ |
| 44 | +export class BaroTransitionAlertManager { |
| 45 | + private static readonly ALTITUDE_MARGIN = 200; // feet |
| 46 | + private static readonly ALTITUDE_HYSTERESIS = 80; // feet |
| 47 | + |
| 48 | + private readonly publisher = this.bus.getPublisher<BaroTransitionAlertEvents>(); |
| 49 | + |
| 50 | + private readonly id: string; |
| 51 | + |
| 52 | + private readonly adcIndex: Subscribable<number>; |
| 53 | + |
| 54 | + private readonly isAltitudeDataValid = ConsumerSubject.create(null, false); |
| 55 | + private readonly indicatedAlt = ConsumerSubject.create(null, 0); |
| 56 | + private readonly baroIsStdActive = ConsumerSubject.create(null, false); |
| 57 | + |
| 58 | + private readonly indicatedAltRounded = this.indicatedAlt.map(SubscribableMapFunctions.withPrecision(1)); |
| 59 | + |
| 60 | + private readonly canAlertAltitude = MappedSubject.create( |
| 61 | + ([isEnabled, threshold, isAltitudeDataValid, isStdActive]) => isEnabled && threshold >= 0 && isAltitudeDataValid && !isStdActive, |
| 62 | + this.settingManager.getSetting('baroTransitionAlertAltitudeEnabled'), |
| 63 | + this.settingManager.getSetting('baroTransitionAlertAltitudeThreshold'), |
| 64 | + this.isAltitudeDataValid, |
| 65 | + this.baroIsStdActive, |
| 66 | + ); |
| 67 | + private readonly canAlertLevel = MappedSubject.create( |
| 68 | + ([isEnabled, threshold, isAltitudeDataValid, isStdActive]) => isEnabled && threshold >= 0 && isAltitudeDataValid && isStdActive, |
| 69 | + this.settingManager.getSetting('baroTransitionAlertLevelEnabled'), |
| 70 | + this.settingManager.getSetting('baroTransitionAlertLevelThreshold'), |
| 71 | + this.isAltitudeDataValid, |
| 72 | + this.baroIsStdActive, |
| 73 | + ); |
| 74 | + |
| 75 | + private readonly altitudeInputs = MappedSubject.create( |
| 76 | + this.settingManager.getSetting('baroTransitionAlertAltitudeThreshold'), |
| 77 | + this.indicatedAltRounded |
| 78 | + ); |
| 79 | + private readonly levelInputs = MappedSubject.create( |
| 80 | + this.settingManager.getSetting('baroTransitionAlertLevelThreshold'), |
| 81 | + this.indicatedAltRounded |
| 82 | + ); |
| 83 | + |
| 84 | + private readonly altitudeAlertState = Subject.create(AlertState.Off); |
| 85 | + private readonly altitudeLastActiveAlt = Value.create(0); |
| 86 | + |
| 87 | + private readonly levelAlertState = Subject.create(AlertState.Off); |
| 88 | + private readonly levelLastActiveAlt = Value.create(0); |
| 89 | + |
| 90 | + private isAlive = true; |
| 91 | + private isInit = false; |
| 92 | + private isResumed = false; |
| 93 | + |
| 94 | + private adcIndexSub?: Subscription; |
| 95 | + |
| 96 | + private altitudeInputsSub?: Subscription; |
| 97 | + private levelInputsSub?: Subscription; |
| 98 | + |
| 99 | + private altitudeCanAlertSub?: Subscription; |
| 100 | + private levelCanAlertSub?: Subscription; |
| 101 | + |
| 102 | + /** |
| 103 | + * Creates a new instance of BaroTransitionAlertManager. The manager is created in an uninitialized and paused state. |
| 104 | + * @param bus The event bus. |
| 105 | + * @param settingManager A manager for barometric transition alert user settings. |
| 106 | + * @param options Options with which to configure the manager. |
| 107 | + * @throws Error if `options.id` is the empty string. |
| 108 | + */ |
| 109 | + public constructor( |
| 110 | + private readonly bus: EventBus, |
| 111 | + private readonly settingManager: UserSettingManager<BaroTransitionAlertUserSettingTypes>, |
| 112 | + options: Readonly<BaroTransitionAlertManagerOptions>, |
| 113 | + ) { |
| 114 | + if (options.id === '') { |
| 115 | + throw new Error('BaroTransitionAlertManager: ID cannot be the empty string'); |
| 116 | + } |
| 117 | + |
| 118 | + this.id = options.id; |
| 119 | + |
| 120 | + this.adcIndex = SubscribableUtils.toSubscribable(options.adcIndex, true); |
| 121 | + } |
| 122 | + |
| 123 | + /** |
| 124 | + * Initializes this manager. Once initialized, the manager will be ready to automatically control the states of its |
| 125 | + * barometric transition alerts. |
| 126 | + * @throws Error if this manager has been destroyed. |
| 127 | + */ |
| 128 | + public init(): void { |
| 129 | + if (!this.isAlive) { |
| 130 | + throw new Error('BaroTransitionAlertManager::init(): cannot initialize a dead manager'); |
| 131 | + } |
| 132 | + |
| 133 | + if (this.isInit) { |
| 134 | + return; |
| 135 | + } |
| 136 | + |
| 137 | + this.isInit = true; |
| 138 | + |
| 139 | + const isAlertActive = (state: AlertState): boolean => state === AlertState.Active || state === AlertState.ActiveLocked; |
| 140 | + this.altitudeAlertState.map(isAlertActive).sub(this.publishEvent.bind(this, `baro_transition_alert_altitude_active_${this.id}`), true); |
| 141 | + this.levelAlertState.map(isAlertActive).sub(this.publishEvent.bind(this, `baro_transition_alert_level_active_${this.id}`), true); |
| 142 | + |
| 143 | + this.adcIndexSub = this.adcIndex.sub(this.onAdcIndexChanged.bind(this), true); |
| 144 | + |
| 145 | + this.altitudeInputsSub = this.altitudeInputs.sub(this.updateAlertState.bind(this, this.altitudeAlertState, this.altitudeLastActiveAlt, 1), false, true); |
| 146 | + this.levelInputsSub = this.levelInputs.sub(this.updateAlertState.bind(this, this.levelAlertState, this.levelLastActiveAlt, -1), false, true); |
| 147 | + |
| 148 | + this.altitudeCanAlertSub = this.canAlertAltitude.sub(this.onCanAlertChanged.bind(this, this.altitudeAlertState, this.altitudeInputsSub), false, true); |
| 149 | + this.levelCanAlertSub = this.canAlertLevel.sub(this.onCanAlertChanged.bind(this, this.levelAlertState, this.levelInputsSub), false, true); |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * Resets this manager such that its managed alerts are deactivated. This method has no effect if the manager is not |
| 154 | + * initialized. |
| 155 | + * @throws Error if this manager has been destroyed. |
| 156 | + */ |
| 157 | + public reset(): void { |
| 158 | + if (!this.isAlive) { |
| 159 | + throw new Error('BaroTransitionAlertManager::reset(): cannot reset a dead manager'); |
| 160 | + } |
| 161 | + |
| 162 | + if (!this.isInit) { |
| 163 | + return; |
| 164 | + } |
| 165 | + |
| 166 | + if (this.canAlertAltitude.get()) { |
| 167 | + this.altitudeAlertState.set(AlertState.Unarmed); |
| 168 | + } |
| 169 | + |
| 170 | + if (this.canAlertLevel.get()) { |
| 171 | + this.levelAlertState.set(AlertState.Unarmed); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * Resumes this manager. When the manager is resumed, it will automatically control the states of its barometric |
| 177 | + * transition alerts. This method has no effect if the manager is not initialized. |
| 178 | + * @throws Error if this manager has been destroyed. |
| 179 | + */ |
| 180 | + public resume(): void { |
| 181 | + if (!this.isAlive) { |
| 182 | + throw new Error('BaroTransitionAlertManager::resume(): cannot resume a dead manager'); |
| 183 | + } |
| 184 | + |
| 185 | + if (!this.isInit || this.isResumed) { |
| 186 | + return; |
| 187 | + } |
| 188 | + |
| 189 | + this.isAltitudeDataValid.resume(); |
| 190 | + this.indicatedAlt.resume(); |
| 191 | + this.baroIsStdActive.resume(); |
| 192 | + |
| 193 | + this.canAlertAltitude.resume(); |
| 194 | + this.canAlertLevel.resume(); |
| 195 | + |
| 196 | + this.altitudeInputs.resume(); |
| 197 | + this.levelInputs.resume(); |
| 198 | + |
| 199 | + this.altitudeCanAlertSub!.resume(true); |
| 200 | + this.levelCanAlertSub!.resume(true); |
| 201 | + } |
| 202 | + |
| 203 | + /** |
| 204 | + * Pauses this manager. When the manager is paused, it will not change the states of its barometric transition |
| 205 | + * alerts, and the alerts will remain in the state they were in when the manager was paused. This method has no |
| 206 | + * effect if the manager is not initialized. |
| 207 | + * @throws Error if this manager has been destroyed. |
| 208 | + */ |
| 209 | + public pause(): void { |
| 210 | + if (!this.isAlive) { |
| 211 | + throw new Error('BaroTransitionAlertManager::pause(): cannot pause a dead manager'); |
| 212 | + } |
| 213 | + |
| 214 | + if (!this.isInit || !this.isResumed) { |
| 215 | + return; |
| 216 | + } |
| 217 | + |
| 218 | + this.isAltitudeDataValid.pause(); |
| 219 | + this.indicatedAlt.pause(); |
| 220 | + this.baroIsStdActive.pause(); |
| 221 | + |
| 222 | + this.canAlertAltitude.pause(); |
| 223 | + this.canAlertLevel.pause(); |
| 224 | + |
| 225 | + this.altitudeInputs.pause(); |
| 226 | + this.levelInputs.pause(); |
| 227 | + |
| 228 | + this.altitudeCanAlertSub!.pause(); |
| 229 | + this.levelCanAlertSub!.pause(); |
| 230 | + |
| 231 | + this.altitudeInputsSub!.pause(); |
| 232 | + this.levelInputsSub!.pause(); |
| 233 | + } |
| 234 | + |
| 235 | + /** |
| 236 | + * Publishes an event bus topic from `BaroTransitionAlertEvents`. |
| 237 | + * @param topic The topic to publish. |
| 238 | + * @param data The topic data. |
| 239 | + */ |
| 240 | + private publishEvent<K extends keyof BaroTransitionAlertEvents>(topic: K, data: BaroTransitionAlertEvents[K]): void { |
| 241 | + this.publisher.pub(topic, data, true, true); |
| 242 | + } |
| 243 | + |
| 244 | + /** |
| 245 | + * Responds to when the index of the ADC from which this manager sources data changes. |
| 246 | + * @param index The index of the new ADC from which this manager sources data. |
| 247 | + */ |
| 248 | + private onAdcIndexChanged(index: number): void { |
| 249 | + const sub = this.bus.getSubscriber<AdcSystemEvents>(); |
| 250 | + |
| 251 | + if (index >= 0) { |
| 252 | + this.isAltitudeDataValid.reset(false, sub.on(`adc_altitude_data_valid_${index}`)); |
| 253 | + this.indicatedAlt.reset(0, sub.on(`adc_indicated_alt_${index}`)); |
| 254 | + this.baroIsStdActive.reset(false, sub.on(`adc_altimeter_baro_is_std_${index}`)); |
| 255 | + } else { |
| 256 | + this.isAltitudeDataValid.reset(false); |
| 257 | + this.indicatedAlt.reset(0); |
| 258 | + this.baroIsStdActive.reset(false); |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + /** |
| 263 | + * Responds to when whether one of this manager's alerts can be activated changes. |
| 264 | + * @param alertState The state of the alert. |
| 265 | + * @param inputsSub The subscription that updates the state of the alert when it can be activated. |
| 266 | + * @param canAlert Whether the alert can be activated. |
| 267 | + */ |
| 268 | + private onCanAlertChanged(alertState: Subject<AlertState>, inputsSub: Subscription, canAlert: boolean): void { |
| 269 | + if (canAlert) { |
| 270 | + alertState.set(AlertState.Unarmed); |
| 271 | + inputsSub.resume(true); |
| 272 | + } else { |
| 273 | + inputsSub.pause(); |
| 274 | + alertState.set(AlertState.Off); |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + /** |
| 279 | + * Updates the state of one of this manager's alerts. |
| 280 | + * @param alertState The state of the alert to update. |
| 281 | + * @param lastActiveAlt The altitude threshold, in feet, that triggered the most recent activation of the alert to |
| 282 | + * update. |
| 283 | + * @param direction +1 if the alert is activated while climbing through its altitude threshold, or -1 if the alert is |
| 284 | + * activated while descending through the threshold. |
| 285 | + * @param inputs The input data for the alert state, as |
| 286 | + * `[alert altitude threshold (feet), indicated altitude (feet)]`. |
| 287 | + */ |
| 288 | + private updateAlertState( |
| 289 | + alertState: Subject<AlertState>, |
| 290 | + lastActiveAlt: Value<number>, |
| 291 | + direction: 1 | -1, |
| 292 | + inputs: readonly [number, number] |
| 293 | + ): void { |
| 294 | + const threshold = inputs[0]; |
| 295 | + const thresholdWithMargin = threshold * direction - BaroTransitionAlertManager.ALTITUDE_MARGIN; |
| 296 | + const altitude = inputs[1] * direction; |
| 297 | + |
| 298 | + const state = alertState.get(); |
| 299 | + switch (state) { |
| 300 | + case AlertState.Active: |
| 301 | + if (threshold === lastActiveAlt.get()) { |
| 302 | + if (altitude < thresholdWithMargin - BaroTransitionAlertManager.ALTITUDE_HYSTERESIS) { |
| 303 | + alertState.set(AlertState.Armed); |
| 304 | + } |
| 305 | + break; |
| 306 | + } else { |
| 307 | + // If the alert is active but the threshold altitude has changed, then lock the alert to the active state |
| 308 | + // until it meets the activation criteria for the new threshold, at which point we revert back to the normal |
| 309 | + // active state. |
| 310 | + alertState.set(AlertState.ActiveLocked); |
| 311 | + } |
| 312 | + // fallthrough |
| 313 | + case AlertState.ActiveLocked: |
| 314 | + case AlertState.Armed: |
| 315 | + if (altitude >= thresholdWithMargin) { |
| 316 | + alertState.set(AlertState.Active); |
| 317 | + lastActiveAlt.set(threshold); |
| 318 | + } |
| 319 | + break; |
| 320 | + default: // Unarmed or Off |
| 321 | + if (altitude < thresholdWithMargin) { |
| 322 | + alertState.set(AlertState.Armed); |
| 323 | + } |
| 324 | + } |
| 325 | + } |
| 326 | + |
| 327 | + /** |
| 328 | + * Destroys this manager. Destroying the manager stops it from automatically controlling the states of its barometric |
| 329 | + * transition alerts and allows resources used by the manager to be freed. |
| 330 | + */ |
| 331 | + public destroy(): void { |
| 332 | + this.isAlive = false; |
| 333 | + |
| 334 | + this.adcIndexSub?.destroy(); |
| 335 | + |
| 336 | + this.isAltitudeDataValid.destroy(); |
| 337 | + this.indicatedAlt.destroy(); |
| 338 | + this.baroIsStdActive.destroy(); |
| 339 | + |
| 340 | + this.canAlertAltitude.destroy(); |
| 341 | + this.canAlertLevel.destroy(); |
| 342 | + |
| 343 | + this.altitudeInputs.destroy(); |
| 344 | + this.levelInputs.destroy(); |
| 345 | + } |
| 346 | +} |
0 commit comments