Repository navigation
Expand file tree
/
Copy pathsimulator.py
More file actions
3051 lines (2554 loc) · 168 KB
/
Copy pathsimulator.py
File metadata and controls
3051 lines (2554 loc) · 168 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
from absl import logging
from abc import ABC, abstractmethod
import time
import numpy as np
import tensorflow as tf
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from actor_model import ActorModelVariantA, ActorStatus
from plot_utils import flush_figure_generator
class PredefinedFunctions:
"""Predefined functions that can be called for particle simulation.
Currently, only one type of function, namely a ramp function, is supported.
"""
def __init__(self):
"""Initializes the predefined functions."""
# for __call__
self._functions_dict = {'ramp': self._generate_ramp}
def __call__(self):
"""Returns a dict of key-function-pairs containing all predefined functions.
:returns: A dict of key-function-pairs containing all predefined functions.
"""
return self._functions_dict
def __contains__(self, item):
return item in self._functions_dict.keys()
def __getitem__(self, item):
if not item in self:
raise ValueError('{} not in the list of supported functions. Choose on of {}.'.format(
item, self._functions_dict.keys()))
return self._functions_dict[item]
@staticmethod
def _generate_ramp(low, high, start=None, end=None):
"""Generates a function of the time step that ramps up from low to high.
/\
| _____ high
| /
| /
| low ______/
-------------|----|-------------> time step
start end
:param low: A list or a np.array of arbitrary shape representing the low level of the ramp.
:param high: A list or a np.array of arbitrary shape representing the high level of the ramp.
:param start: None or an integer, the time step when to start the ramp.
:param end: None or an integer, the time step when to stop the ramp.
:returns: A function that describes the ramp. The function output is a np.array.
"""
low = np.asarray(low)
high = np.asarray(high)
def ramp_func(k):
if k < start:
param = low
elif k < end:
param = low + (k - start) * (high - low) / (end - start)
else:
param = high
return param
return ramp_func
class ActorSimulator(ActorModelVariantA):
"""A simulator for the actors in discrete time. Builds upon ActorModelVariantA."""
def __init__(self,
pos,
length,
T,
t_activate,
t_up,
t_hit,
t_down,
t_reset,
width=None,
init_t_act=0.0,
init_k=0,
suppress_warnings=False,
):
"""Initializes the simulator.
:param pos: A np.array of shape [num_actors, 2] representing the positions of the actors. Each position is
modeled as [position_x, position_y], with the x-axis pointing in the direction of particle movement and the
y-axis being orthogonal to the direction of particle movement. Note that this needs to be a sorted list
array from smallest x-position to largest x-position. Actors with the same x-position need to be sorted from
smallest y-position to largest y-position.
:param length: A float or a np.array of shape [num_actors] representing the lengths (operating ranges in
x-direction) of the actors.
:param T: A float representing the time interval between two consecutive time steps.
:param t_activate: A float, the time interval after an activation until the actor reaches the surface.
:param t_up: A float, the time interval above the surface until reaching the optimal hitting time. The particle
is not ejected but disturbed or only ejected with a reduced probability when hit during this time period.
:param t_hit: A float, the time interval when a hitting is optimal, i.e., the particles is ejected.
:param t_down: A float, the time interval above the surface after t_hit until reaching the surface again.
The particle is not ejected but disturbed or only ejected with a reduced probability when hit during this
time period.
:param t_reset: A float, the reset time. The time interval after an activation before a new activation is
possible.
:param width: None or a float or a np.array of shape [num_actors] representing the with (operating ranges in
y-direction) of the actors. If None, an 1D optical sorter (groove sorter) is assumed. If given, a 2D
optical sorter (belt or chute) is assumed.
:param init_t_act: A float or a np.array of shape [num_actors] representing the initial actors' internal time
at time step k.
:param init_k: An integer, the initial time step.
:param suppress_warnings: A Boolean, if True, warnings if an actor is activated when it is not in a READY status
are suppressed. If False, warnings are shown.
"""
if not np.isscalar(init_t_act) and len(pos) != len(np.asarray(init_t_act)):
raise ValueError('If init_t_act is an array it must have num_actors elements')
if not isinstance(init_k, int) or init_k < 0:
raise ValueError('The initial time step must be must be an integer greater than or equal to 0.')
super().__init__(pos=pos,
length=length,
T=T,
t_activate=t_activate,
t_up=t_up,
t_hit=t_hit,
t_down=t_down,
t_reset=t_reset,
width=width,
)
self.suppress_warnings = suppress_warnings
# initialize the arrays/states
self._t_act = init_t_act * np.ones(pos.shape[0]) if np.isscalar(
init_t_act) else np.asarray(init_t_act) # np.array of length num_actors
self._s_act = self.output_actor_status(self._t_act)
self._k = init_k
# save the initial values for later reuse
self._init_t_act = self._t_act.copy()
self._init_k = init_k
@property
def t_act(self):
"""The internal time of the actors.
:returns: A np.array of shape [num_actors] representing the actors' current internal time.
"""
return self._t_act
@property
def s_act(self):
"""The status of the actors.
:returns: An object np.array of shape [num_actors] representing the actors' current status.
"""
return self._s_act
@property
def initial_time_step(self):
"""The initial time step.
:returns: An integer representing the initial time step.
"""
return self._init_k
def predict_own_actor_state(self, u_act):
"""Predicts the actors' internal times and status for the next time step.
This function predicts the state managed by the ActorSimulator class.
:param u_act: A Boolean np.array of shape [num_actors] representing the control inputs u_k at time step k, where
True indicates that the actor should be activated in the current time step.
"""
invalid_activation = np.logical_and(u_act,
np.logical_not(np.logical_or(self._s_act == ActorStatus.READY,
np.logical_and(
self._s_act == ActorStatus.RESET,
self._t_act == self.t_cycle))))
assert np.logical_not(np.any(np.logical_and(self._s_act == ActorStatus.RESET, self.t_act == 0.0))), (
'Actor in RESET position with t_act = 0. '
'This indicates that your controller is accidentally manipulating the actor states.')
if not self.suppress_warnings and np.any(invalid_activation):
logging.warning(
'Actor at index {} can only be activated if it is in ready position.'.format(
np.nonzero(invalid_activation)[0]))
self._k += 1
self._t_act, self._s_act = self.predict_actor_state(self._t_act, u_act)
def reset(self):
"""Resets the actors to the initial state."""
self._t_act = self._init_t_act.copy()
self._s_act = self.output_actor_status(self._t_act)
self._k = self._init_k
def shallow_copy(self):
"""Creates a shallow copy of the actor simulator, i.e., a new instance of the actor simulator with the same
settings but independent, freshly-initialized states.
:returns: An ActorSimulator instance, a shallow copy of the actor simulator.
"""
return ActorSimulator(pos=self._pos,
length=self._length,
T=self._T,
t_activate=self._t_activate,
t_up=self._t_up,
t_hit=self._t_hit,
t_down=self._t_down,
t_reset=self._t_reset,
width=self._width,
init_t_act=self._init_t_act,
init_k=self._init_k,
suppress_warnings=self.suppress_warnings,
)
class AbstractParticleSimulator(ABC):
"""Abstract class for discrete-time simulators of the particles on a sorting machine, either on the groove (1D
movement) or on a belt or chute (2D movement), including tracks births and deaths.
Internally uses constant-velocity (CV) and/or constant-acceleration motion models for simulation of the particle
movement.
The class supports particle-specific and time-variant motion models, i.e., the motion models can be different for
each particle class and can change over time. This can be used to model different materials (particle types) on the
sorting machine as well as changing conditions over time.
"""
def __init__(self,
total_num_particles,
T,
process_parameters=({"type": "CV"},
{"type": "CV"}),
init_k=0,
seed=None,
freeze_scenario=False,
S_v=0.0,
):
"""Initializes the particle simulator.
:param total_num_particles: An integer, the total number of particles to generate during the simulation.
:param T: A float representing the time interval between two consecutive time steps.
:param process_parameters: A tuple of length num_processes containing dicts, where each dict has at least
a key 'type' describing the type of the process, e.g., a CV-model, and the remaining fields contain the
parameters of the process. Each process may describe a material in the mass flow. Note that time-variant
process parameters are supported. See self._define_processes for a detailed documentation of supported
types.
:param init_k: An integer, the initial time step.
:param seed: None or an integer, the seed for the random number generator. Fix the seed for reproducibility.
:param freeze_scenario: A Boolean, if True, the scenario, including the total number of particles and the seed
for the random number generator are fixed to the initial value, i.e., each time after resetting the
simulator, the same settings are restored and the scenario is generated. If False, the scenario is not
frozen and a new run after resetting is allowed to be different from the ones before.
:param S_v: A float or a sequence of two floats, the measurement-noise variance used by measure_particles(...).
A float is used for 1D simulators; a sequence of length 2 for 2D simulators (x- and y-direction). Defaults
to 0.0 (noise-free measurements).
"""
if not isinstance(total_num_particles, int) or total_num_particles <= 0:
raise ValueError(
'The total number of particles to generate total_num_particles must be a positive integer.')
if not np.isscalar(T) or T <= 0:
raise ValueError('The time interval between two consecutive time steps T must be a positive scalar.')
if not isinstance(init_k, int) or init_k < 0:
raise ValueError('The initial time step must be must be an integer greater than or equal to 0.')
if freeze_scenario and seed is None:
raise ValueError('If freeze_scenario is True, a seed for the random number generator must be given.')
if np.any(np.asarray(S_v) < 0):
raise ValueError('The measurement-noise variance S_v must be greater than or equal to zero.')
self._total_num_particles = total_num_particles
self._T = float(T)
self._S_v = S_v
# get the predefined functions
self._predefined_functions = PredefinedFunctions()
# define the processes
self._process_parameters = len(process_parameters) * [{}]
self._process_type = len(process_parameters) * [None]
self._define_processes(process_parameters) # will manipulate self._process_parameters and self._process_type
# set the seed and create the random number generators, we need two generators, one for the particle motion
# prediction and one for the particle initialization to make sure that particle initialization is independent of
# the subsequent particle motion and thus the controller and therefore ensure reproducibility of the scenarios
self._rng_for_particle_prediction = np.random.default_rng(seed)
self._seed_offset = 456789123
self._rng_for_particle_initialization = np.random.default_rng(
seed + self._seed_offset if seed is not None else None)
self._freeze_scenario = freeze_scenario
# initialize the arrays/states
self._particle_id = np.empty(0, dtype=int)
self._particle_class = np.empty(0, dtype=int)
self._sampled_motion_states = [np.empty((0, process_param["F"](0).shape[0]),
dtype=float) for process_param in self._process_parameters]
self._particle_id_in_motion_states = [np.empty(0, dtype=int) for _ in self._process_parameters]
self._existence = np.empty(0, dtype=bool)
self._counter = 0
# internal time step for simulation
self._k = init_k
# save the initial values for later reuse
self._init_k = init_k
self._original_seed = seed
self._original_total_num_particles = total_num_particles
self._original_process_parameters = process_parameters
@property
@abstractmethod
def C_w_pos_velo(self):
"""The system noise covariance for the (position, velocity)-components of the particles.
Note that the acceleration components in case of CA models are not considered here.
:returns: A np.array of shape [num_particle, ...], the system noise covariance of the particles.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@property
def T(self):
"""The sampling time difference.
:returns: A float representing the time interval between two consecutive time steps.
"""
return self._T
@property
def position(self):
"""The current positions of the particles.
:returns: A np.array of shape [num_particle, ...], the positions of the particles.
"""
flattened_positions = np.concatenate(
[self._state_to_pos(motion_state) for motion_state in self._sampled_motion_states], axis=0)
return self._associate_to_particle_id(flattened_positions)
@property
def velocity(self):
"""The current velocities of the particles.
:returns: A np.array of shape [num_particle, ...], the current velocities of the particles.
"""
flattened_velocities = np.concatenate(
[self._state_to_velo(motion_state) for motion_state in self._sampled_motion_states], axis=0)
return self._associate_to_particle_id(flattened_velocities)
@property
def particle_existence(self):
"""The existence of the particles.
:returns: A Boolean np.array of shape [num_particles], the existence of the particles. True indicates that a
particle exists.
"""
return self._existence
@property
def particle_class(self):
"""The class of the particles.
:returns: An integer np.array of shape [num_particles] where each component is either 0 ("keep
the particle") or 1 ("eject the particle") representing the current particle classes.
"""
return self._particle_class
@property
def particle_id(self):
"""The particle IDs.
:returns: An integer np.array of shape [num_particles], the ids of the particles.
"""
return self._particle_id
@property
def total_num_particles(self):
"""The total number of particles to generate during the simulation.
:returns: An integer representing the total number of particles to generate during the simulation.
"""
return self._total_num_particles
@total_num_particles.setter
def total_num_particles(self, value):
"""Resets the particle simulator and sets a new total number of particles.
This option is only available if the scenario is not frozen. If additionally a new seed should be set, call
reset(seed=<new_seed>) before or afterward.
:param value: An integer representing the total number of particles to generate during the simulation.
"""
if not isinstance(value, int) or value <= 0:
raise ValueError(
'The total number of particles to generate total_num_particles must be a positive integer.')
if self._freeze_scenario:
raise ValueError('The scenario is frozen, the total number of particles cannot be changed.')
self._total_num_particles = value
self.reset(seed=None, restore_original_scenario=False) # do not change the seed; restore original scenario
# does not make sense here
@property
def initial_time_step(self):
"""The initial time step.
:returns: An integer representing the initial time step.
"""
return self._init_k
@property
def is_frozen(self):
"""Whether the scenario is frozen.
:returns: A Boolean, whether the scenario is frozen.
"""
return self._freeze_scenario
@property
def num_particle_seen_so_far(self):
"""The number of already generated particles.
Can be used to check/assert against self.total_num_particles to verify the implementation.
:returns: An integer representing the number of already generated particles.
"""
return self._counter
@staticmethod
@abstractmethod
def _state_to_pos(motion_state):
"""Transforms the motion state to the position of the particles.
:param motion_state: A np.array of shape [num_particles, state_length], the motion state.
:returns: A np.array of shape [num_particle, ...], the current positions of the particles.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@staticmethod
@abstractmethod
def _state_to_velo(motion_state):
"""Transforms the motion state to the velocities of the particles.
:param motion_state: A np.array of shape [num_particles, state_length], the motion state.
:returns: A np.array of shape [num_particle, ...], the current velocities of the particles.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@abstractmethod
def _define_cv_model(self, T, *args, **kwargs):
"""Defines a (potentially time-variant) Constant-Velocity (CV) motion model (also known als white-noise
acceleration model).
Note that the units of the parameters must be consistent, but are not restricted to pixels or DEM coordinate
unit and time steps. For example, also normalized values can be used.
:param T: A float representing the time interval between two consecutive time steps.
:returns:
F: A function f(time_step) -> np.array representing the (potentially time-variant) transition matrix of the
model.
C_w: A function f(time_step) -> np.array representing the (potentially time-variant) system noise covariance
of the model.
mean_0: A function f(time_step) -> np.array representing the (potentially time-variant) expected value of
the initial state.
cov_0: A function f(time_step) -> np.array representing the (potentially time-variant) covariance of
the initial state.
S_w: A function f(time_step) -> (float, float) representing the (potentially time-variant) power spectral
density of the system noise in x- and y-direction.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@abstractmethod
def _define_ca_model(self, T, *args, **kwargs):
"""Defines a (potentially time-variant) Constant-Velocity (CV) motion model (also known als white-noise
jerk model).
Note that the units of the parameters must be consistent, but are not restricted to pixels or DEM coordinate
unit and time steps. For example, also normalized values can be used.
:param T: A float representing the time interval between two consecutive time steps.
:returns:
F: A function f(time_step) -> np.array representing the (potentially time-variant) transition matrix of the
model.
C_w: A function f(time_step) -> np.array representing the (potentially time-variant) system noise covariance
of the model.
mean_0: A function f(time_step) -> np.array representing the (potentially time-variant) expected value of
the initial state.
cov_0: A function f(time_step) -> np.array representing the (potentially time-variant) covariance of
the initial state.
S_w: A function f(time_step) -> (float, float) representing the (potentially time-variant) power spectral
density of the system noise in x- and y-direction.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@abstractmethod
def create_particles(self):
"""Creates new particles in the current time step, i.e., initializes new tracks."""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
@abstractmethod
def track_particles(self, measurements):
"""This is a dummy/mock for a tracker for the particles and constitutes the (only) interface for data exchange
to the controllers.
Instead of really tracking the particle, we assume here that we measure the mean of the motion state perfectly
and add an artificial noise for the motion states' covariance.
This mocks outputs the motion state mean and covariance as for a constant velocity model, even if internally
a constant acceleration model is used. This is to be consistent with the controllers, that currently
only support constant velocity motion states.
Format measurements:
[x, label]
with label either 0 ("keep the particle") or 1 ("eject the particle").
:param measurements: A np.array of shape [num_measurements, measurement_vector_length], the measurements
(including x-position and class label) for the current time step. Used as dummy input here.
:returns:
estimated_motion_state_mean: A np.array of shape [num_particles, state_length], the estimated motion state
mean.
estimated_motion_state_cov: A np.array of shape [num_particles, state_length, state_length], the estimated
motion state covariance.
particle_class: An integer np.array of shape [num_particles] where each component is either 0 ("keep the
particle") or 1 ("eject the particle") representing the particle classes.
particle_id: An integer np.array of shape [num_particles], the ids of the particles.
existence: A Boolean np.array of shape [num_particles], the existence of the particles. True indicates that
a particle exists.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
def measure_particles(self):
"""Returns noisy position measurements of currently existing particles for a real multitarget tracker.
Unlike track_particles(...), this method does not return motion-state estimates. It only provides position
measurements (plus class labels) that can be passed to a callable MTT tracker.
Format measurements (1D):
[x, label]
Format measurements (2D):
[x, y, label]
with label either 0 ("keep the particle") or 1 ("eject the particle").
Additive Gaussian measurement noise with variance S_v is applied to the position components. Existing particles
only are included.
:returns: A np.array of shape [num_existing_particles, measurement_vector_length], the measurements for the
current time step. If no particle exists, an empty array with the appropriate second dimension is returned.
"""
existence = self.particle_existence
position = self.position[existence]
particle_class = self.particle_class[existence]
if position.ndim == 1:
# 1D groove sorter
if not np.isscalar(self._S_v):
raise ValueError('For 1D particle simulators, S_v must be a scalar.')
if position.shape[0] == 0:
return np.empty((0, 2))
noise = self._rng_for_particle_prediction.normal(
loc=0.0, scale=np.sqrt(self._S_v), size=position.shape)
return np.column_stack((position + noise, particle_class))
# 2D area sorter
s_v = np.asarray(self._S_v, dtype=float)
if s_v.size == 1:
s_v = np.repeat(s_v, 2)
if s_v.size != 2:
raise ValueError('For 2D particle simulators, S_v must be a scalar or a sequence of length 2.')
if position.shape[0] == 0:
return np.empty((0, 3))
noise = self._rng_for_particle_prediction.normal(loc=0.0, scale=1.0, size=position.shape)
noise *= np.sqrt(s_v)
return np.column_stack((position + noise, particle_class))
@abstractmethod
def _shallow_copy(self, seed=None, freeze_scenario=False):
"""Creates a shallow copy of the particle simulator, i.e., a new instance of the particle simulator with the
same settings but independent, freshly-initialized states.
Note that the copy will be initialized with the current self.total_num_particles particles, i.e., after
resetting the copy with reset(restore_original_scenario=True), the copy will by default work with
self.total_num_particles particles.
:param seed: None or an integer, the seed for the random number generator. Fix the seed for reproducibility.
:param freeze_scenario: A Boolean, if True, the scenario, including the total number of particles and the seed
for the random number generator are fixed to the initial value, i.e., each time after resetting the
simulator, the same settings are restored and the scenario is generated. If False, the scenario is not
frozen and a new run after resetting is allowed to be different from the ones before.
:returns: An AbstractParticleSimulator instance, a shallow copy of the particle simulator.
"""
# To be overwritten by subclass
raise NotImplementedError('Call to abstract method.')
def _define_processes(self, process_parameters_ls):
"""Defines the (potentially time-variant) processes to be used for data generation, given a list of dicts
describing the processes.
Format dicts:
{'type': process_type,
... further key-value pairs}],
with process_type a string indicating the type of the process and further key-value pairs describing fields
for its parameters.
Processes can be defined in two ways (we have two process types):
i) CV-model (process_type = 'CV'):
Fields with the keys
- S_w, pos_0, v_0,
- and for wo-dimensional motion additionally: pos0_stddev, v0_stddev
ii) CA-model (process_type = 'CA'):
Fields with the keys
- S_w, S_v, pos_0, v_0, a_0,
- and for wo-dimensional motion additionally: pos0_stddev, v0_stddev, a0_stddev
For the value of each of the keys either
* a float, if one-dimensional motion, or
a tuple of floats with length 2, if two-dimensional motion,
* a function f(times) --> float, if one-dimensional motion, or
f(time_step) -> (float, float), if two-dimensional motion,
* a string representing a lambda function f(time_step) -> (float), if one-dimensional motion, or
f(time_step) -> (float, float), if two-dimensional motion,
* a string representing a function call (with args and/or kwargs)) of predefined
function f(time_step) -> float, if one-dimensional motion, or
f(time_step) -> (float, float), if two-dimensional motion,
can be passed. If a key is not given, default values will be used.
Please refer to _define_cv_model, and _define_ca_model for further documentation.
Example (for one-dimensional motion):
process_parameters_ls = [
{'type': 'CV'},
{'type': 'CA', 'S_w': 600.0, 'v_0': 0.0},
{'type': 'CV', 'S_w': lambda k: 2 * k + 400.0},
{'type': 'CV', 'S_w': 'lambda k: 2 * k + 400.0'},
{'type': 'CV', 'S_w': 'ramp(400, 40000)'},
]
Example (for two-dimensional motion):
process_parameters_ls = [
{'type': 'CV'},
{'type': 'CA', 'S_w': (600.0, 400.0) , 'v_0': (1.0, 0.0)},
{'type': 'CV', 'S_w': lambda k: 2 * k + 400.0, 200},
{'type': 'CV', 'S_w': 'lambda k: 2 * k + 400.0, 200'},
{'type': 'CV', 'S_w': 'ramp([400, 400], [40000, 400])'},
]
:param process_parameters_ls: A list of length num_processes containing dicts, where each dict has at least a
key 'type' describing the type of the process, e.g., a CV-model, and the remaining fields contain the
parameters of the process. Valid values for the key 'type' are 'CV' or 'CA'. The further fields (if given)
may have key-values pairs as explained above.
"""
for process_no, process_dict in enumerate(process_parameters_ls):
dict = process_dict.copy()
process_type = dict.pop('type')
self._process_parameters[process_no] = {}
if process_type == "CV":
F, C_w, mean_0, cov_0, S_w = self._define_cv_model(T=self._T, **dict)
self._process_parameters[process_no]["S_w"] = S_w
elif process_type == "CA":
F, C_w, mean_0, cov_0, S_w = self._define_ca_model(T=self._T, **dict)
self._process_parameters[process_no]["S_w"] = S_w
else:
raise ValueError(
"Type {} not defined for process_parameters_ls. Key must be be one of ['CV', 'CA']".format(
process_type))
self._process_parameters[process_no]["F"], self._process_parameters[process_no]["C_w"], \
self._process_parameters[process_no]["mean_0"], self._process_parameters[process_no][
"cov_0"] = F, C_w, mean_0, cov_0
self._process_type[process_no] = process_type
def _define_func(self, value):
"""Wraps the value into a function of desired format, if not already done.
Value can be passed in five ways:
i) as a static float or int.
ii) as a (static) tuple of floats or integers,
iii) as function f(time_step) -> (float/int, float/int), or f(time_step) -> float/int,
iv) as a string representing a lambda function f(time_step) -> (float/int, float/int),
or f(time_step) -> float/int,
v) as a string representing a function call (with args and/or kwargs) of predefined function
f(time_step) -> (float/int, float/int), or f(time_step) -> float/int,
Examples:
value = 1.0
value = (1.0, 2.0)
value = lambda k: (1, 2*k) or lambda k: 2*k,
value = 'lambda k: (1, 2*k)' or 'lambda k: 2*k',
value = 'ramp((0, 1), (1, 1), start=0, end=1)', or 'ramp(1, start=0, end=1)'
:param value: A value to be wrapped into a function. Type and format as described above.
:returns: A function f(time_step) -> np.array of shape [1 or 2].
"""
if not callable(value):
if isinstance(value, str):
if value[:6] == "lambda":
fn = eval(value)
func = lambda k: np.asarray(fn(k))
else:
func_name = value.rsplit('(')[0]
if func_name in self._predefined_functions:
func = eval(value, {'__builtins__': None}, self._predefined_functions())
else:
raise ValueError(
'Function {} is not a predefined function of simulator.py.'.format(func_name))
elif isinstance(value, (list, tuple, np.ndarray)):
if len(value) != 1 and len(value) != 2:
raise ValueError(
'If using is a list or tuple for parameter specification, it needs to have a length of 1 or 2.')
func = lambda k: np.array([*value])
elif isinstance(value, (float, int)):
func = lambda k: np.array([value])
else:
raise ValueError(
'For parameter specification, pass either a scalar, list, tuple, a function, or a function call of '
'a predefined function, not {}.'.format(value))
else:
func = lambda k: np.asarray(value(k))
if func(0).ndim != 1 and len(func(0)) != 2:
raise ValueError(
'If using a function for parameter specification, its return value needs to be transferable to a '
'one-dimensional np.array of length 1 or 2.')
return func
def move_particles(self):
"""Moves the particles, i.e., predicts the next state of the particles according to the defined motion models.
Note that this function supports particle-specific and time-variant motion models.
"""
for process_no, process_param in enumerate(self._process_parameters):
F, C_w = process_param["F"], process_param["C_w"]
self._sampled_motion_states[process_no] = np.matmul(F(self._k),
self._sampled_motion_states[process_no][:, :,
None]).squeeze(-1)
w_t = self._rng_for_particle_prediction.multivariate_normal(mean=np.zeros(len(C_w(self._k))),
cov=C_w(self._k),
size=len(
self._sampled_motion_states[process_no]))
self._sampled_motion_states[process_no] += w_t
self._k += 1
def remove_particles(self, remove_mask):
"""Removes particles from the simulation.
A particle is typically removed if it is ejected from the sorting machine or if it leaves its operating area.
:param remove_mask: A Boolean np.array of shape [num_particles] used as a mask to remove particles. True
indicates that a particle should be removed.
"""
particles_to_keep = np.logical_not(remove_mask)
self._particle_id = self._particle_id[particles_to_keep]
for process_no, particle_id_from_process in enumerate(self._particle_id_in_motion_states):
still_in_mask = np.isin(particle_id_from_process, self._particle_id)
self._sampled_motion_states[process_no] = self._sampled_motion_states[process_no][still_in_mask]
self._particle_id_in_motion_states[process_no] = particle_id_from_process[still_in_mask]
self._existence = self._existence[particles_to_keep]
self._particle_class = self._particle_class[particles_to_keep]
def negate_existence(self, hit_mask):
"""Sets the existence of particles to False.
A particle is typically negated if it is ejected from the sorting machine.
:param hit_mask: A Boolean np.array of shape [num_particles] used as a mask to negate the existence of
particles. True indicates that a particle should be negated.
"""
self._existence[hit_mask] = False
def reset(self, seed=None, restore_original_scenario=False):
"""Resets the particle simulator to the initial state.
:param seed: None or an integer, the seed for the random number generator. If given, the random number
generator is reset to seed. If None, the random number generator is not reset. This option is only available
if restore_original_scenario and self._freeze_scenario are False.
:param restore_original_scenario: A Boolean, if True, the scenario, including the total number of particles and
the seed for the random number generator are reset to the initial value, i.e., the run after resetting the
simulator will be the same as the first run. If False and self._freeze_scenario is False, the scenario is
not fixed and the run will be different from the ones before. This option is only available if seed is None.
"""
if seed is not None and (restore_original_scenario or self._freeze_scenario):
raise ValueError('The seed cannot be set if restore_original_scenario or self._freeze_scenario is True.')
# initialize the arrays/states
self._particle_id = np.empty(0, dtype=int)
self._particle_class = np.empty(0, dtype=int)
self._sampled_motion_states = [np.empty((0, process_param['F'](0).shape[0]), dtype=float) for process_param in
self._process_parameters]
self._particle_id_in_motion_states = [np.empty(0, dtype=int) for _ in self._process_parameters]
self._existence = np.empty(0, dtype=bool)
self._counter = 0
self._k = self._init_k
if seed is not None:
self._rng_for_particle_prediction = np.random.default_rng(seed)
self._rng_for_particle_initialization = np.random.default_rng(seed + self._seed_offset)
elif restore_original_scenario or self._freeze_scenario:
# use the same seed as the first, run will be reproducible
if self._original_seed is None:
raise ValueError('The original seed is None, the scenario cannot be restored to the original one.')
self._rng_for_particle_prediction = np.random.default_rng(self._original_seed)
self._rng_for_particle_initialization = np.random.default_rng(self._original_seed + self._seed_offset)
self._total_num_particles = self._original_total_num_particles
def shallow_copy(self, seed=None, freeze_scenario=False):
"""Creates a shallow copy of the particle simulator, i.e., a new instance of the particle simulator with the
same settings but independent, freshly-initialized states.
Note that the copy will be initialized with the current self.total_num_particles particles, i.e., after
resetting the copy with reset(restore_original_scenario=True), the copy will by default work with
self.total_num_particles particles.
:param seed: None or an integer, the seed for the random number generator. Fix the seed for reproducibility.
:param freeze_scenario: A Boolean, if True, the scenario, including the total number of particles and the seed
for the random number generator are fixed to the initial value, i.e., each time after resetting the
simulator, the same settings are restored and the scenario is generated. If False, the scenario is not
frozen and a new run after resetting is allowed to be different from the ones before.
:returns: An AbstractParticleSimulator instance, a shallow copy of the particle simulator.
"""
return self._shallow_copy(seed=self._original_seed if freeze_scenario and seed is None else seed,
freeze_scenario=freeze_scenario)
def _associate_to_particle_id(self, flattened_array):
"""Associates the flattened array, indexed with self._particle_id_in_motion_states, to the self._particle_id.
Example:
self._particle_id = [1, 2, 3] , self._particle_id_in_motion_states = [[3, 2], [1]]
[[0, 0, 1]
--> association_matrix = [0, 1, 0] in particular, association_matrix @ [3, 2, 1] = [1, 2, 3]
[1, 0, 0]]
:param flattened_array: A np.array of shape [num_particles, ...] to be associated to the self._particle_id.
:returns: A np.array of shape [num_particles, ...] with the same shape as flattened_array, but associated to
self._particle_id.
"""
flattened_ids = np.hstack([particle_id for particle_id in self._particle_id_in_motion_states])
association_matrix = np.equal(self._particle_id[:, None], flattened_ids[None, :]).astype(int)
return association_matrix @ flattened_array
class AreaParticleSimulator(AbstractParticleSimulator):
"""A discrete-time simulator for the particles on a belt or chute (2D movement), including tracks births and deaths.
.
Internally uses constant-velocity (CV) and/or constant-acceleration motion models for simulation of the particle
movement.
The class supports particle-specific and time-variant motion models, i.e., the motion models can be different for
each particle class and can change over time. This can be used to model different materials (particle types) on the
sorting machine as well as changing conditions over time.
This class uses a process/motion model specific birth_rate_mean_and_stddev to define the number of particles of each
process that spawn in each time step. The processes are mapped to the eject and the accept class via class_labels.
Therefore, it is possible to defined arbitrary many processes/motion models, as long as there is at least one for
the reject and one for the accept class). In contrast to a 1D groove sorter, it is not possible to
predefine the class of each particle. The ratio of accept and eject particles depends on the
birth_rate_mean_and_stddev of all processes. The simulation terminates, if total_num_particles particles have left
the sorting machine.
"""
def __init__(self,
total_num_particles,
T,
process_parameters,
birth_rate_mean_and_stddev,
class_labels,
area_width,
particle_r=0.5,
init_k=0,
seed=None,
freeze_scenario=False,
S_v=(0.0, 0.0),
):
"""Initializes the particle simulator.
Note that process parameters should be such that there is enough probability mass to spawn particles in
[particle_r and area_width - particle_r], orthogonal to the transport direction and [-particle_x_velocity * T,
0] along the transport direction (note that the field of view of the simulation is [0, array_end] x
[0, area_width], spawning particles at x < 0 therefore is visually more appealing).
:param total_num_particles: An integer, the total number of particles to generate during the simulation.
:param T: A float representing the time interval between two consecutive time steps.
:param process_parameters: A tuple of length 2 containing dicts, where each dict has at least
a key 'type' describing the type of the process, e.g., a CV-model, and the remaining fields contain the
parameters of the process. Each process may describe a material in the mass flow. Note that time-variant
process parameters are supported. See self._define_processes for a detailed documentation of supported
types.
:param birth_rate_mean_and_stddev: A list of length num_processes containing (mean, stddev)-tuples describing
the mass flows, i.e., the number of track births per time steps. Note that time-variant mean and stddevs are
supported. See self._define_mass_flows for a detailed documentation of supported types.
:param class_labels: A list of length num_processes where each component is either 0 ("keep the particle") or
1 ("eject the particle") representing the particle class for each process.
:param area_width: A float representing the total width of the belt or chute.
:param particle_r: A float representing the radius of the simulated particles.
:param init_k: An integer, the initial time step.
:param seed: None or an integer, the seed for the random number generator. Fix the seed for reproducibility.
:param freeze_scenario: A Boolean, if True, the scenario, including the total number of particles and the seed
for the random number generator are fixed to the initial value, i.e., each time after resetting the
simulator, the same settings are restored and the scenario is generated. If False, the scenario is not
frozen and a new run after resetting is allowed to be different from the ones before.
:param S_v: A float or a sequence of two floats, the measurement-noise variance used by measure_particles(...)
in x- and y-direction. Defaults to (0.0, 0.0) (noise-free measurements).
"""
if not np.isscalar(area_width) or area_width <= 0:
raise ValueError('The width of the belt or chute area_width must be a positive scalar.')
if not np.isscalar(particle_r) or particle_r <= 0:
raise ValueError('The particle radius particle_r must be a positive scalar.')
if len(birth_rate_mean_and_stddev) != len(process_parameters) or len(birth_rate_mean_and_stddev) != len(
class_labels):
raise ValueError(
'The process_parameters, birth_rate_mean_and_stddev, and class_labels must be of same length.')
if np.all(np.unique(class_labels) != np.array([0, 1])):
raise ValueError('The class labels must be either 0 or 1 and both labels must be present.')
s_v = np.asarray(S_v, dtype=float)
if s_v.size == 1:
S_v = (float(s_v), float(s_v))
elif s_v.size == 2:
S_v = (float(s_v[0]), float(s_v[1]))
else:
raise ValueError('For AreaParticleSimulator, S_v must be a scalar or a sequence of length 2.')
self._area_width = area_width
self._particle_r = particle_r
super().__init__(total_num_particles=total_num_particles,
T=T,
process_parameters=process_parameters,
init_k=init_k,
seed=seed,
freeze_scenario=freeze_scenario,
S_v=S_v)
# define the mass flows
self._birth_rate_mean_and_stddev = len(birth_rate_mean_and_stddev) * [None]
self._define_mass_flows(birth_rate_mean_and_stddev) # will manipulate self._birth_rate_mean_and_stddev
self._class_labels = class_labels
# save the values for later reuse
self._original_birth_rate_mean_and_stddev = self._birth_rate_mean_and_stddev.copy()
@property
def C_w_pos_velo(self):
"""The system noise covariance for the (position, velocity)-components of the particles.
Note that the acceleration components in case of CA models are not considered here.
:returns: A np.array of shape [num_particles, 4, 4], the system noise covariance of the particles.
"""
C_w = np.empty((len(self._particle_id), 4, 4), dtype=float)
i = 0
for process_type, process_parameters, motion_state in zip(self._process_type, self._process_parameters,
self._sampled_motion_states):
C_w_process = process_parameters["C_w"](self._k)
if np.all(C_w_process == 0):