@@ -98,7 +98,14 @@ def __init__(
9898 self .source_obs_from_mqtt = source_obs_from_mqtt
9999 self .ignore_cache = ignore_cache
100100 self .time_of_previous_observation : datetime | None = None
101- self .expected_dt = 1 / (60 * 60 * config .getfloat ("od_reading.config" , "samples_per_second" ))
101+ self .expected_dt = 1 / (
102+ 60 * 60 * config .getfloat ("od_reading.config" , "samples_per_second" )
103+ ) # in hours
104+
105+ # ekf parameters for when a dosing event occurs
106+ self ._obs_since_last_dose : int | None = None
107+ self ._obs_required_to_reset : int | None = None
108+ self ._recent_dilution = False
102109
103110 def on_ready (self ) -> None :
104111 # this is here since the below is long running, and if kept in the init(), there is a large window where
@@ -133,7 +140,6 @@ def on_ready(self) -> None:
133140 self .logger .debug (f"od_normalization_mean={ self .od_normalization_factors } " )
134141 self .logger .debug (f"od_normalization_variance={ self .od_variances } " )
135142 self .ekf = self .initialize_extended_kalman_filter (
136- acc_std = config .getfloat ("growth_rate_kalman" , "acc_std" ),
137143 od_std = config .getfloat ("growth_rate_kalman" , "od_std" ),
138144 rate_std = config .getfloat ("growth_rate_kalman" , "rate_std" ),
139145 obs_std = config .getfloat ("growth_rate_kalman" , "obs_std" ),
@@ -143,32 +149,29 @@ def on_ready(self) -> None:
143149 self .start_passive_listeners ()
144150
145151 def initialize_extended_kalman_filter (
146- self , acc_std : float , od_std : float , rate_std : float , obs_std : float
152+ self , od_std : float , rate_std : float , obs_std : float
147153 ) -> CultureGrowthEKF :
148154 import numpy as np
149155
150156 initial_state = np .array (
151157 [
152158 self .initial_nOD ,
153159 self .initial_growth_rate ,
154- self .initial_acc ,
155160 ]
156161 )
157162 self .logger .debug (f"Initial state: { repr (initial_state )} " )
158163
159164 initial_covariance = 1e-4 * np .eye (
160- 3
165+ 2
161166 ) # empirically selected - TODO: this should probably scale with `expected_dt`
162167 self .logger .debug (f"Initial covariance matrix:\n { repr (initial_covariance )} " )
163168
164- acc_process_variance = (acc_std * self .expected_dt ) ** 2
165169 od_process_variance = (od_std * self .expected_dt ) ** 2
166170 rate_process_variance = (rate_std * self .expected_dt ) ** 2
167171
168- process_noise_covariance = np .zeros ((3 , 3 ))
172+ process_noise_covariance = np .zeros ((2 , 2 ))
169173 process_noise_covariance [0 , 0 ] = od_process_variance
170174 process_noise_covariance [1 , 1 ] = rate_process_variance
171- process_noise_covariance [2 , 2 ] = acc_process_variance
172175 self .logger .debug (f"Process noise covariance matrix:\n { repr (process_noise_covariance )} " )
173176
174177 observation_noise_covariance = self .create_obs_noise_covariance (obs_std )
@@ -371,21 +374,6 @@ def get_od_variances_from_cache(self) -> dict[pt.PdChannel, float]:
371374
372375 return variances
373376
374- def update_ekf_variance_after_event (self , minutes : float , factor : float ) -> None :
375- if whoami .is_testing_env ():
376- # TODO: replace with jobmanager
377- msg = subscribe ( # needs to be pubsub.subscribe (ie not sub_client.subscribe) since this is called in a callback
378- f"pioreactor/{ self .unit } /{ self .experiment } /od_reading/interval" ,
379- timeout = 1.0 ,
380- )
381- if msg :
382- interval = float (msg .payload )
383- else :
384- interval = 5
385- self .ekf .scale_OD_variance_for_next_n_seconds (factor , minutes * (12 * interval ))
386- else :
387- self .ekf .scale_OD_variance_for_next_n_seconds (factor , minutes * 60 )
388-
389377 def scale_raw_observations (self , observations : dict [pt .PdChannel , float ]) -> dict [pt .PdChannel , float ]:
390378 def _scale_and_shift (obs , shift , scale ) -> float :
391379 return (obs - shift ) / (scale - shift )
@@ -474,9 +462,19 @@ def _update_state_from_observation(
474462
475463 self .time_of_previous_observation = timestamp
476464
477- updated_state_ , covariance_ = self .ekf .update (list (scaled_observations .values ()), dt )
465+ updated_state_ , covariance_ = self .ekf .update (
466+ list (scaled_observations .values ()), dt , self ._recent_dilution
467+ )
478468 latest_od_filtered , latest_growth_rate = float (updated_state_ [0 ]), float (updated_state_ [1 ])
479469
470+ if self ._obs_since_last_dose is not None and self ._obs_required_to_reset is not None :
471+ self ._obs_since_last_dose += 1
472+
473+ if self ._obs_since_last_dose >= self ._obs_required_to_reset :
474+ self ._obs_since_last_dose = None
475+ self ._obs_required_to_reset = None
476+ self ._recent_dilution = False
477+
480478 growth_rate = structs .GrowthRate (
481479 growth_rate = latest_growth_rate ,
482480 timestamp = timestamp ,
@@ -499,21 +497,9 @@ def respond_to_dosing_event_from_mqtt(self, message: pt.MQTTMessage) -> None:
499497 return self .respond_to_dosing_event (dosing_event )
500498
501499 def respond_to_dosing_event (self , dosing_event : structs .DosingEvent ) -> None :
502- # here we can add custom logic to handle dosing events.
503- # an improvement to this: the variance factor is proportional to the amount exchanged.
504- if dosing_event .event != "remove_waste" :
505- self .update_ekf_variance_after_event (
506- minutes = config .getfloat (
507- "growth_rate_calculating.config" ,
508- "ekf_variance_shift_post_dosing_minutes" ,
509- fallback = 0.40 ,
510- ),
511- factor = config .getfloat (
512- "growth_rate_calculating.config" ,
513- "ekf_variance_shift_post_dosing_factor" ,
514- fallback = 2500 ,
515- ),
516- )
500+ self ._obs_since_last_dose = 0
501+ self ._obs_required_to_reset = 1
502+ self ._recent_dilution = True
517503
518504 def start_passive_listeners (self ) -> None :
519505 # process incoming data
0 commit comments