pyelq 1.1.1__py3-none-any.whl → 1.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyelq/component/source_model.py +176 -52
- pyelq/dlm.py +5 -3
- pyelq/model.py +132 -14
- pyelq/plotting/plot.py +76 -29
- pyelq/support_functions/post_processing.py +21 -32
- {pyelq-1.1.1.dist-info → pyelq-1.1.3.dist-info}/METADATA +5 -3
- {pyelq-1.1.1.dist-info → pyelq-1.1.3.dist-info}/RECORD +10 -10
- {pyelq-1.1.1.dist-info → pyelq-1.1.3.dist-info}/WHEEL +1 -1
- {pyelq-1.1.1.dist-info → pyelq-1.1.3.dist-info}/LICENSE.md +0 -0
- {pyelq-1.1.1.dist-info → pyelq-1.1.3.dist-info}/LICENSES/Apache-2.0.txt +0 -0
pyelq/component/source_model.py
CHANGED
|
@@ -19,7 +19,7 @@ A SourceModel instance inherits from 3 super-classes:
|
|
|
19
19
|
from abc import abstractmethod
|
|
20
20
|
from copy import deepcopy
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
|
-
from typing import TYPE_CHECKING, Tuple, Union
|
|
22
|
+
from typing import TYPE_CHECKING, Optional, Tuple, Union
|
|
23
23
|
|
|
24
24
|
import numpy as np
|
|
25
25
|
from openmcmc import parameter
|
|
@@ -43,7 +43,50 @@ if TYPE_CHECKING:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
@dataclass
|
|
46
|
-
class
|
|
46
|
+
class ParameterMapping:
|
|
47
|
+
"""Class for defining mapping variable/parameter labels needed for creating an analysis.
|
|
48
|
+
|
|
49
|
+
In instances where we want to include multiple source_model instances in an MCMC analysis, we can apply a suffix to
|
|
50
|
+
all of the parameter names in the mapping dictionary. This allows us to create separate variables for different
|
|
51
|
+
source map types, so that these can be associated with different sampler types in the MCMC analysis.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
map (dict): dictionary containing mapping between variable types and MCMC parameters.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
map: dict = field(
|
|
59
|
+
default_factory=lambda: {
|
|
60
|
+
"source": "s",
|
|
61
|
+
"coupling_matrix": "A",
|
|
62
|
+
"emission_rate_mean": "mu_s",
|
|
63
|
+
"emission_rate_precision": "lambda_s",
|
|
64
|
+
"allocation": "alloc_s",
|
|
65
|
+
"source_prob": "s_prob",
|
|
66
|
+
"precision_prior_shape": "a_lam_s",
|
|
67
|
+
"precision_prior_rate": "b_lam_s",
|
|
68
|
+
"source_location": "z_src",
|
|
69
|
+
"number_sources": "n_src",
|
|
70
|
+
"number_source_rate": "rho",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def append_string(self, string: str = None):
|
|
75
|
+
"""Apply the supplied string as a suffix to all of the values in the mapping dictionary.
|
|
76
|
+
|
|
77
|
+
For example: {'source': 's'} would become {'source': 's_fixed'} when string = 'fixed' is passed as the argument.
|
|
78
|
+
If string is None, nothing is appended.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
string (str): string to append to the variable names.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
for key, value in self.map.items():
|
|
85
|
+
self.map[key] = value + "_" + string
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SourceGrouping(ParameterMapping):
|
|
47
90
|
"""Superclass for source grouping approach.
|
|
48
91
|
|
|
49
92
|
Source grouping method determines the group allocation of each source in the model, e.g: slab and spike
|
|
@@ -52,13 +95,11 @@ class SourceGrouping:
|
|
|
52
95
|
Attributes:
|
|
53
96
|
nof_sources (int): number of sources in the model.
|
|
54
97
|
emission_rate_mean (Union[float, np.ndarray]): prior mean parameter for the emission rate distribution.
|
|
55
|
-
_source_key (str): label for the source parameter to be used in the distributions, samplers, MCMC state etc.
|
|
56
98
|
|
|
57
99
|
"""
|
|
58
100
|
|
|
59
101
|
nof_sources: int = field(init=False)
|
|
60
102
|
emission_rate_mean: Union[float, np.ndarray] = field(init=False)
|
|
61
|
-
_source_key: str = field(init=False, default="s")
|
|
62
103
|
|
|
63
104
|
@abstractmethod
|
|
64
105
|
def make_allocation_model(self, model: list) -> list:
|
|
@@ -118,8 +159,14 @@ class NullGrouping(SourceGrouping):
|
|
|
118
159
|
2) The case where the dimensionality of the source map is changing during the inversion, and a common prior
|
|
119
160
|
mean and precision term are used for all sources.
|
|
120
161
|
|
|
162
|
+
Attributes:
|
|
163
|
+
number_on_sources (np.ndarray): number of sources switched on in the solution, per iteration. Extracted as a
|
|
164
|
+
property from the MCMC samples in self.from_mcmc_group().
|
|
165
|
+
|
|
121
166
|
"""
|
|
122
167
|
|
|
168
|
+
number_on_sources: np.ndarray = field(init=False)
|
|
169
|
+
|
|
123
170
|
def make_allocation_model(self, model: list) -> list:
|
|
124
171
|
"""Initialise the source allocation part of the model.
|
|
125
172
|
|
|
@@ -163,20 +210,19 @@ class NullGrouping(SourceGrouping):
|
|
|
163
210
|
dict: state updated with parameters related to the source grouping.
|
|
164
211
|
|
|
165
212
|
"""
|
|
166
|
-
state["
|
|
167
|
-
state["
|
|
213
|
+
state[self.map["emission_rate_mean"]] = np.array(self.emission_rate_mean, ndmin=1)
|
|
214
|
+
state[self.map["allocation"]] = np.zeros((self.nof_sources, 1), dtype="int")
|
|
168
215
|
return state
|
|
169
216
|
|
|
170
217
|
def from_mcmc_group(self, store: dict):
|
|
171
218
|
"""Extract posterior allocation samples from the MCMC sampler, attach them to the class.
|
|
172
219
|
|
|
173
|
-
|
|
174
|
-
NullGrouping Class.
|
|
220
|
+
Gets the number of sources present in each iteration of the MCMC sampler, and attaches this as a class property.
|
|
175
221
|
|
|
176
222
|
Args:
|
|
177
223
|
store (dict): dictionary containing samples from the MCMC.
|
|
178
|
-
|
|
179
224
|
"""
|
|
225
|
+
self.number_on_sources = np.count_nonzero(np.logical_not(np.isnan(store[self.map["source"]])), axis=0)
|
|
180
226
|
|
|
181
227
|
|
|
182
228
|
@dataclass
|
|
@@ -190,11 +236,13 @@ class SlabAndSpike(SourceGrouping):
|
|
|
190
236
|
slab_probability (float): prior probability of allocation to the slab component. Defaults to 0.05.
|
|
191
237
|
allocation (np.ndarray): set of allocation samples, with shape=(n_sources, n_iterations). Attached to
|
|
192
238
|
the class by self.from_mcmc_group().
|
|
239
|
+
number_on_sources (np.ndarray): number of sources switched on in the solution, per iteration.
|
|
193
240
|
|
|
194
241
|
"""
|
|
195
242
|
|
|
196
243
|
slab_probability: float = 0.05
|
|
197
244
|
allocation: np.ndarray = field(init=False)
|
|
245
|
+
number_on_sources: np.ndarray = field(init=False)
|
|
198
246
|
|
|
199
247
|
def make_allocation_model(self, model: list) -> list:
|
|
200
248
|
"""Initialise the source allocation part of the model.
|
|
@@ -206,7 +254,7 @@ class SlabAndSpike(SourceGrouping):
|
|
|
206
254
|
list: overall model list, updated with allocation distribution.
|
|
207
255
|
|
|
208
256
|
"""
|
|
209
|
-
model.append(Categorical("
|
|
257
|
+
model.append(Categorical(self.map["allocation"], prob=self.map["source_prob"]))
|
|
210
258
|
return model
|
|
211
259
|
|
|
212
260
|
def make_allocation_sampler(self, model: Model, sampler_list: list) -> list:
|
|
@@ -220,7 +268,9 @@ class SlabAndSpike(SourceGrouping):
|
|
|
220
268
|
list: sampler_list updated with sampler for the source allocation.
|
|
221
269
|
|
|
222
270
|
"""
|
|
223
|
-
sampler_list.append(
|
|
271
|
+
sampler_list.append(
|
|
272
|
+
MixtureAllocation(param=self.map["allocation"], model=model, response_param=self.map["source"])
|
|
273
|
+
)
|
|
224
274
|
return sampler_list
|
|
225
275
|
|
|
226
276
|
def make_allocation_state(self, state: dict) -> dict:
|
|
@@ -233,9 +283,11 @@ class SlabAndSpike(SourceGrouping):
|
|
|
233
283
|
dict: state updated with parameters related to the source grouping.
|
|
234
284
|
|
|
235
285
|
"""
|
|
236
|
-
state["
|
|
237
|
-
state["
|
|
238
|
-
|
|
286
|
+
state[self.map["emission_rate_mean"]] = np.array(self.emission_rate_mean, ndmin=1)
|
|
287
|
+
state[self.map["source_prob"]] = np.tile(
|
|
288
|
+
np.array([self.slab_probability, 1 - self.slab_probability]), (self.nof_sources, 1)
|
|
289
|
+
)
|
|
290
|
+
state[self.map["allocation"]] = np.ones((self.nof_sources, 1), dtype="int")
|
|
239
291
|
return state
|
|
240
292
|
|
|
241
293
|
def from_mcmc_group(self, store: dict):
|
|
@@ -245,11 +297,12 @@ class SlabAndSpike(SourceGrouping):
|
|
|
245
297
|
store (dict): dictionary containing samples from the MCMC.
|
|
246
298
|
|
|
247
299
|
"""
|
|
248
|
-
self.allocation = store["
|
|
300
|
+
self.allocation = store[self.map["allocation"]]
|
|
301
|
+
self.number_on_sources = self.allocation.shape[0] - np.sum(self.allocation, axis=0)
|
|
249
302
|
|
|
250
303
|
|
|
251
304
|
@dataclass
|
|
252
|
-
class SourceDistribution:
|
|
305
|
+
class SourceDistribution(ParameterMapping):
|
|
253
306
|
"""Superclass for source emission rate distribution.
|
|
254
307
|
|
|
255
308
|
Source distribution determines the type of prior to be used for the source emission rates, and the transformation
|
|
@@ -349,9 +402,13 @@ class NormalResponse(SourceDistribution):
|
|
|
349
402
|
|
|
350
403
|
model.append(
|
|
351
404
|
mcmcNormal(
|
|
352
|
-
"
|
|
353
|
-
mean=parameter.MixtureParameterVector(
|
|
354
|
-
|
|
405
|
+
self.map["source"],
|
|
406
|
+
mean=parameter.MixtureParameterVector(
|
|
407
|
+
param=self.map["emission_rate_mean"], allocation=self.map["allocation"]
|
|
408
|
+
),
|
|
409
|
+
precision=parameter.MixtureParameterMatrix(
|
|
410
|
+
param=self.map["emission_rate_precision"], allocation=self.map["allocation"]
|
|
411
|
+
),
|
|
355
412
|
domain_response_lower=domain_response_lower,
|
|
356
413
|
)
|
|
357
414
|
)
|
|
@@ -370,7 +427,7 @@ class NormalResponse(SourceDistribution):
|
|
|
370
427
|
"""
|
|
371
428
|
if sampler_list is None:
|
|
372
429
|
sampler_list = []
|
|
373
|
-
sampler_list.append(NormalNormal("
|
|
430
|
+
sampler_list.append(NormalNormal(self.map["source"], model))
|
|
374
431
|
return sampler_list
|
|
375
432
|
|
|
376
433
|
def make_source_state(self, state: dict) -> dict:
|
|
@@ -383,7 +440,7 @@ class NormalResponse(SourceDistribution):
|
|
|
383
440
|
dict: state updated with initial emission rate vector.
|
|
384
441
|
|
|
385
442
|
"""
|
|
386
|
-
state["
|
|
443
|
+
state[self.map["source"]] = np.zeros((self.nof_sources, 1))
|
|
387
444
|
return state
|
|
388
445
|
|
|
389
446
|
def from_mcmc_dist(self, store: dict):
|
|
@@ -393,7 +450,7 @@ class NormalResponse(SourceDistribution):
|
|
|
393
450
|
store (dict): dictionary containing samples from the MCMC.
|
|
394
451
|
|
|
395
452
|
"""
|
|
396
|
-
self.emission_rate = store["
|
|
453
|
+
self.emission_rate = store[self.map["source"]]
|
|
397
454
|
|
|
398
455
|
|
|
399
456
|
@dataclass
|
|
@@ -447,12 +504,19 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
447
504
|
initial_precision (Union[float, np.ndarray]): initial value for the source emission rate precision parameter.
|
|
448
505
|
precision_scalar (np.ndarray): precision values generated by MCMC inversion.
|
|
449
506
|
|
|
507
|
+
all_source_locations (ENU): ENU object containing the locations of after the mcmc has been run, therefore in
|
|
508
|
+
the situation where the reversible_jump == True, this will be the final locations of the sources in the
|
|
509
|
+
solution over all iterations. For the case where reversible_jump == False, this will be the locations of
|
|
510
|
+
the sources in the source map and will not change during the course of the inversion.
|
|
511
|
+
individual_source_labels (list, optional): list of labels for each source in the source map, defaults to None.
|
|
512
|
+
|
|
450
513
|
coverage_detection (float): sensor detection threshold (in ppm) to be used for coverage calculations.
|
|
451
514
|
coverage_test_source (float): test source (in kg/hr) which we wish to be able to see in coverage calculation.
|
|
452
515
|
|
|
453
516
|
threshold_function (Callable): Callable function which returns a single value that defines the threshold
|
|
454
517
|
for the coupling in a lambda function form. Examples: lambda x: np.quantile(x, 0.95, axis=0),
|
|
455
518
|
lambda x: np.max(x, axis=0), lambda x: np.mean(x, axis=0). Defaults to np.quantile.
|
|
519
|
+
label_string (str): string to append to the parameter mapping, e.g. for fixed sources, defaults to None.
|
|
456
520
|
|
|
457
521
|
"""
|
|
458
522
|
|
|
@@ -476,11 +540,26 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
476
540
|
initial_precision: Union[float, np.ndarray] = 1.0
|
|
477
541
|
precision_scalar: np.ndarray = field(init=False)
|
|
478
542
|
|
|
543
|
+
all_source_locations: np.ndarray = field(init=False)
|
|
544
|
+
individual_source_labels: Optional[list] = None
|
|
545
|
+
|
|
479
546
|
coverage_detection: float = 0.1
|
|
480
547
|
coverage_test_source: float = 6.0
|
|
481
548
|
|
|
482
549
|
threshold_function: callable = lambda x: np.quantile(x, 0.95, axis=0)
|
|
483
550
|
|
|
551
|
+
label_string: Optional[str] = None
|
|
552
|
+
|
|
553
|
+
def __post_init__(self):
|
|
554
|
+
"""Post-initialisation of the class.
|
|
555
|
+
|
|
556
|
+
This function is called after the class has been initialised,
|
|
557
|
+
and is used to set up the mapping dictionary for the class by applying the
|
|
558
|
+
append_string function to the mapping dictionary.
|
|
559
|
+
"""
|
|
560
|
+
if self.label_string is not None:
|
|
561
|
+
self.append_string(self.label_string)
|
|
562
|
+
|
|
484
563
|
@property
|
|
485
564
|
def nof_sources(self):
|
|
486
565
|
"""Get number of sources in the source map."""
|
|
@@ -583,15 +662,17 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
583
662
|
state (dict): state dictionary containing updated coupling information.
|
|
584
663
|
|
|
585
664
|
"""
|
|
586
|
-
self.dispersion_model.source_map.location.from_array(state["
|
|
665
|
+
self.dispersion_model.source_map.location.from_array(state[self.map["source_location"]][:, [update_column]].T)
|
|
587
666
|
new_coupling = self.dispersion_model.compute_coupling(
|
|
588
667
|
self.sensor_object, self.meteorology, self.gas_species, output_stacked=True, run_interpolation=False
|
|
589
668
|
)
|
|
590
669
|
|
|
591
|
-
if update_column == state["
|
|
592
|
-
state["
|
|
593
|
-
|
|
594
|
-
|
|
670
|
+
if update_column == state[self.map["coupling_matrix"]].shape[1]:
|
|
671
|
+
state[self.map["coupling_matrix"]] = np.concatenate(
|
|
672
|
+
(state[self.map["coupling_matrix"]], new_coupling), axis=1
|
|
673
|
+
)
|
|
674
|
+
elif update_column < state[self.map["coupling_matrix"]].shape[1]:
|
|
675
|
+
state[self.map["coupling_matrix"]][:, [update_column]] = new_coupling
|
|
595
676
|
else:
|
|
596
677
|
raise ValueError("Invalid column specification for updating.")
|
|
597
678
|
return state
|
|
@@ -629,10 +710,12 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
629
710
|
(i.e. log[p(current | proposed)])
|
|
630
711
|
|
|
631
712
|
"""
|
|
632
|
-
prop_state = self.update_coupling_column(prop_state, int(prop_state["
|
|
633
|
-
prop_state["
|
|
713
|
+
prop_state = self.update_coupling_column(prop_state, int(prop_state[self.map["number_sources"]]) - 1)
|
|
714
|
+
prop_state[self.map["allocation"]] = np.concatenate(
|
|
715
|
+
(prop_state[self.map["allocation"]], np.array([0], ndmin=2)), axis=0
|
|
716
|
+
)
|
|
634
717
|
in_cov_area = self.dispersion_model.compute_coverage(
|
|
635
|
-
prop_state["
|
|
718
|
+
prop_state[self.map["coupling_matrix"]][:, -1],
|
|
636
719
|
coverage_threshold=self.coverage_threshold,
|
|
637
720
|
threshold_function=self.threshold_function,
|
|
638
721
|
)
|
|
@@ -644,8 +727,7 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
644
727
|
|
|
645
728
|
return prop_state, logp_pr_g_cr, logp_cr_g_pr
|
|
646
729
|
|
|
647
|
-
|
|
648
|
-
def death_function(current_state: dict, prop_state: dict, deletion_index: int) -> Tuple[dict, float, float]:
|
|
730
|
+
def death_function(self, current_state: dict, prop_state: dict, deletion_index: int) -> Tuple[dict, float, float]:
|
|
649
731
|
"""Update MCMC state based on source death proposal.
|
|
650
732
|
|
|
651
733
|
Proposed state updated as follows:
|
|
@@ -669,8 +751,10 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
669
751
|
(i.e. log[p(current | proposed)])
|
|
670
752
|
|
|
671
753
|
"""
|
|
672
|
-
prop_state["
|
|
673
|
-
|
|
754
|
+
prop_state[self.map["coupling_matrix"]] = np.delete(
|
|
755
|
+
prop_state[self.map["coupling_matrix"]], obj=deletion_index, axis=1
|
|
756
|
+
)
|
|
757
|
+
prop_state[self.map["allocation"]] = np.delete(prop_state[self.map["allocation"]], obj=deletion_index, axis=0)
|
|
674
758
|
logp_pr_g_cr = 0.0
|
|
675
759
|
logp_cr_g_pr = 0.0
|
|
676
760
|
|
|
@@ -693,7 +777,7 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
693
777
|
prop_state = deepcopy(current_state)
|
|
694
778
|
prop_state = self.update_coupling_column(prop_state, update_column)
|
|
695
779
|
in_cov_area = self.dispersion_model.compute_coverage(
|
|
696
|
-
prop_state["
|
|
780
|
+
prop_state[self.map["coupling_matrix"]][:, update_column],
|
|
697
781
|
coverage_threshold=self.coverage_threshold,
|
|
698
782
|
threshold_function=self.threshold_function,
|
|
699
783
|
)
|
|
@@ -714,16 +798,22 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
714
798
|
model = self.make_allocation_model(model)
|
|
715
799
|
model = self.make_source_model(model)
|
|
716
800
|
if self.update_precision:
|
|
717
|
-
model.append(
|
|
801
|
+
model.append(
|
|
802
|
+
Gamma(
|
|
803
|
+
self.map["emission_rate_precision"],
|
|
804
|
+
shape=self.map["precision_prior_shape"],
|
|
805
|
+
rate=self.map["precision_prior_rate"],
|
|
806
|
+
)
|
|
807
|
+
)
|
|
718
808
|
if self.reversible_jump:
|
|
719
809
|
model.append(
|
|
720
810
|
Uniform(
|
|
721
|
-
response="
|
|
811
|
+
response=self.map["source_location"],
|
|
722
812
|
domain_response_lower=self.site_limits[:, [0]],
|
|
723
813
|
domain_response_upper=self.site_limits[:, [1]],
|
|
724
814
|
)
|
|
725
815
|
)
|
|
726
|
-
model.append(Poisson(response="
|
|
816
|
+
model.append(Poisson(response=self.map["number_sources"], rate=self.map["number_source_rate"]))
|
|
727
817
|
return model
|
|
728
818
|
|
|
729
819
|
def make_sampler(self, model: Model, sampler_list: list) -> list:
|
|
@@ -740,7 +830,7 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
740
830
|
sampler_list = self.make_source_sampler(model, sampler_list)
|
|
741
831
|
sampler_list = self.make_allocation_sampler(model, sampler_list)
|
|
742
832
|
if self.update_precision:
|
|
743
|
-
sampler_list.append(NormalGamma("
|
|
833
|
+
sampler_list.append(NormalGamma(self.map["emission_rate_precision"], model))
|
|
744
834
|
if self.reversible_jump:
|
|
745
835
|
sampler_list = self.make_sampler_rjmcmc(model, sampler_list)
|
|
746
836
|
return sampler_list
|
|
@@ -757,15 +847,15 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
757
847
|
"""
|
|
758
848
|
state = self.make_allocation_state(state)
|
|
759
849
|
state = self.make_source_state(state)
|
|
760
|
-
state["
|
|
761
|
-
state["
|
|
850
|
+
state[self.map["coupling_matrix"]] = self.coupling
|
|
851
|
+
state[self.map["emission_rate_precision"]] = np.array(self.initial_precision, ndmin=1)
|
|
762
852
|
if self.update_precision:
|
|
763
|
-
state["
|
|
764
|
-
state["
|
|
853
|
+
state[self.map["precision_prior_shape"]] = np.ones_like(self.initial_precision) * self.prior_precision_shape
|
|
854
|
+
state[self.map["precision_prior_rate"]] = np.ones_like(self.initial_precision) * self.prior_precision_rate
|
|
765
855
|
if self.reversible_jump:
|
|
766
|
-
state["
|
|
767
|
-
state["
|
|
768
|
-
state["
|
|
856
|
+
state[self.map["source_location"]] = self.dispersion_model.source_map.location.to_array().T
|
|
857
|
+
state[self.map["number_sources"]] = state[self.map["source_location"]].shape[1]
|
|
858
|
+
state[self.map["number_source_rate"]] = self.rate_num_sources
|
|
769
859
|
return state
|
|
770
860
|
|
|
771
861
|
def make_sampler_rjmcmc(self, model: Model, sampler_list: list) -> list:
|
|
@@ -785,11 +875,13 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
785
875
|
sampler_list (list): list of samplers updated with samplers corresponding to RJMCMC routine.
|
|
786
876
|
|
|
787
877
|
"""
|
|
788
|
-
|
|
878
|
+
for sampler in sampler_list:
|
|
879
|
+
if sampler.param == self.map["source"]:
|
|
880
|
+
sampler.max_variable_size = self.n_sources_max
|
|
789
881
|
|
|
790
882
|
sampler_list.append(
|
|
791
883
|
RandomWalkLoop(
|
|
792
|
-
"
|
|
884
|
+
self.map["source_location"],
|
|
793
885
|
model,
|
|
794
886
|
step=self.random_walk_step_size,
|
|
795
887
|
max_variable_size=(3, self.n_sources_max),
|
|
@@ -797,13 +889,18 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
797
889
|
state_update_function=self.move_function,
|
|
798
890
|
)
|
|
799
891
|
)
|
|
800
|
-
matching_params = {
|
|
892
|
+
matching_params = {
|
|
893
|
+
"variable": self.map["source"],
|
|
894
|
+
"matrix": self.map["coupling_matrix"],
|
|
895
|
+
"scale": 1.0,
|
|
896
|
+
"limits": [0.0, 1e6],
|
|
897
|
+
}
|
|
801
898
|
sampler_list.append(
|
|
802
899
|
ReversibleJump(
|
|
803
|
-
"
|
|
900
|
+
self.map["number_sources"],
|
|
804
901
|
model,
|
|
805
902
|
step=np.array([1.0], ndmin=2),
|
|
806
|
-
associated_params="
|
|
903
|
+
associated_params=self.map["source_location"],
|
|
807
904
|
n_max=self.n_sources_max,
|
|
808
905
|
state_birth_function=self.birth_function,
|
|
809
906
|
state_death_function=self.death_function,
|
|
@@ -815,14 +912,41 @@ class SourceModel(Component, SourceGrouping, SourceDistribution):
|
|
|
815
912
|
def from_mcmc(self, store: dict):
|
|
816
913
|
"""Extract results of mcmc from mcmc.store and attach to components.
|
|
817
914
|
|
|
915
|
+
For the reversible jump case we extract all estimated source locations
|
|
916
|
+
per iteration. For the fixed sources case we grab the source locations
|
|
917
|
+
from the inputted sourcemap and repeat those for all iterations.
|
|
918
|
+
|
|
818
919
|
Args:
|
|
819
920
|
store (dict): mcmc result dictionary.
|
|
820
921
|
|
|
821
922
|
"""
|
|
822
923
|
self.from_mcmc_group(store)
|
|
823
924
|
self.from_mcmc_dist(store)
|
|
925
|
+
if self.individual_source_labels is None:
|
|
926
|
+
self.individual_source_labels = list(np.repeat(None, store[self.map["source"]].shape[0]))
|
|
927
|
+
|
|
824
928
|
if self.update_precision:
|
|
825
|
-
self.precision_scalar = store["
|
|
929
|
+
self.precision_scalar = store[self.map["emission_rate_precision"]]
|
|
930
|
+
|
|
931
|
+
if self.reversible_jump:
|
|
932
|
+
reference_latitude = self.dispersion_model.source_map.location.ref_latitude
|
|
933
|
+
reference_longitude = self.dispersion_model.source_map.location.ref_longitude
|
|
934
|
+
ref_altitude = self.dispersion_model.source_map.location.ref_altitude
|
|
935
|
+
self.all_source_locations = ENU(
|
|
936
|
+
ref_latitude=reference_latitude,
|
|
937
|
+
ref_longitude=reference_longitude,
|
|
938
|
+
ref_altitude=ref_altitude,
|
|
939
|
+
east=store[self.map["source_location"]][0, :, :],
|
|
940
|
+
north=store[self.map["source_location"]][1, :, :],
|
|
941
|
+
up=store[self.map["source_location"]][2, :, :],
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
else:
|
|
945
|
+
location_temp = self.dispersion_model.source_map.location.to_enu()
|
|
946
|
+
location_temp.east = np.repeat(location_temp.east[:, np.newaxis], store["log_post"].shape[0], axis=1)
|
|
947
|
+
location_temp.north = np.repeat(location_temp.north[:, np.newaxis], store["log_post"].shape[0], axis=1)
|
|
948
|
+
location_temp.up = np.repeat(location_temp.up[:, np.newaxis], store["log_post"].shape[0], axis=1)
|
|
949
|
+
self.all_source_locations = location_temp
|
|
826
950
|
|
|
827
951
|
def plot_iterations(self, plot: "Plot", burn_in_value: int, y_axis_type: str = "linear") -> "Plot":
|
|
828
952
|
"""Plot the emission rate estimates source model object against MCMC iteration.
|
pyelq/dlm.py
CHANGED
|
@@ -119,20 +119,22 @@ class DLM:
|
|
|
119
119
|
mean_state_noise = np.zeros(self.nof_state_parameters)
|
|
120
120
|
mean_observation_noise = np.zeros(self.nof_observables)
|
|
121
121
|
|
|
122
|
+
random_generator = np.random.default_rng(seed=None)
|
|
123
|
+
|
|
122
124
|
for i in range(nof_timesteps):
|
|
123
125
|
if i == 0:
|
|
124
126
|
state[:, [i]] = (
|
|
125
127
|
self.g_matrix @ init_state
|
|
126
|
-
+
|
|
128
|
+
+ random_generator.multivariate_normal(mean_state_noise, self.w_matrix, size=1).T
|
|
127
129
|
)
|
|
128
130
|
else:
|
|
129
131
|
state[:, [i]] = (
|
|
130
132
|
self.g_matrix @ state[:, [i - 1]]
|
|
131
|
-
+
|
|
133
|
+
+ random_generator.multivariate_normal(mean_state_noise, self.w_matrix, size=1).T
|
|
132
134
|
)
|
|
133
135
|
obs[:, [i]] = (
|
|
134
136
|
self.f_matrix.T @ state[:, [i]]
|
|
135
|
-
+
|
|
137
|
+
+ random_generator.multivariate_normal(mean_observation_noise, self.v_matrix, size=1).T
|
|
136
138
|
)
|
|
137
139
|
|
|
138
140
|
return state, obs
|
pyelq/model.py
CHANGED
|
@@ -9,6 +9,7 @@ This module provides a class definition for the main functionalities of the code
|
|
|
9
9
|
openMCMC repo and defining some plotting wrappers.
|
|
10
10
|
|
|
11
11
|
"""
|
|
12
|
+
import re
|
|
12
13
|
import warnings
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from typing import Union
|
|
@@ -23,6 +24,7 @@ from pyelq.component.background import Background, SpatioTemporalBackground
|
|
|
23
24
|
from pyelq.component.error_model import BySensor, ErrorModel
|
|
24
25
|
from pyelq.component.offset import PerSensor
|
|
25
26
|
from pyelq.component.source_model import Normal, SourceModel
|
|
27
|
+
from pyelq.coordinate_system import ENU
|
|
26
28
|
from pyelq.gas_species import GasSpecies
|
|
27
29
|
from pyelq.meteorology import Meteorology, MeteorologyGroup
|
|
28
30
|
from pyelq.plotting.plot import Plot
|
|
@@ -62,7 +64,7 @@ class ELQModel:
|
|
|
62
64
|
meteorology: Union[Meteorology, MeteorologyGroup],
|
|
63
65
|
gas_species: GasSpecies,
|
|
64
66
|
background: Background = SpatioTemporalBackground(),
|
|
65
|
-
source_model: SourceModel = Normal(),
|
|
67
|
+
source_model: Union[list, SourceModel] = Normal(),
|
|
66
68
|
error_model: ErrorModel = BySensor(),
|
|
67
69
|
offset_model: PerSensor = None,
|
|
68
70
|
):
|
|
@@ -82,7 +84,9 @@ class ELQModel:
|
|
|
82
84
|
meteorology (Union[Meteorology, MeteorologyGroup]): meteorology data.
|
|
83
85
|
gas_species (GasSpecies): gas species object.
|
|
84
86
|
background (Background): background model specification. Defaults to SpatioTemporalBackground().
|
|
85
|
-
source_model (SourceModel): source model specification.
|
|
87
|
+
source_model (Union[list, SourceModel]): source model specification. This can be a list of multiple
|
|
88
|
+
SourceModels or a single SourceModel. Defaults to Normal(). If a single SourceModel is used, it will
|
|
89
|
+
be converted to a list.
|
|
86
90
|
error_model (Precision): measurement precision model specification. Defaults to BySensor().
|
|
87
91
|
offset_model (PerSensor): offset model specification. Defaults to None.
|
|
88
92
|
|
|
@@ -92,10 +96,19 @@ class ELQModel:
|
|
|
92
96
|
self.gas_species = gas_species
|
|
93
97
|
self.components = {
|
|
94
98
|
"background": background,
|
|
95
|
-
"source": source_model,
|
|
96
99
|
"error_model": error_model,
|
|
97
100
|
"offset": offset_model,
|
|
98
101
|
}
|
|
102
|
+
|
|
103
|
+
if source_model is not None:
|
|
104
|
+
if not isinstance(source_model, list):
|
|
105
|
+
source_model = [source_model]
|
|
106
|
+
for source in source_model:
|
|
107
|
+
if source.label_string is None:
|
|
108
|
+
self.components["source"] = source
|
|
109
|
+
else:
|
|
110
|
+
self.components["source_" + source.label_string] = source
|
|
111
|
+
|
|
99
112
|
if error_model is None:
|
|
100
113
|
self.components["error_model"] = BySensor()
|
|
101
114
|
warnings.warn("None is not an allowed type for error_model: resetting to default BySensor model.")
|
|
@@ -107,17 +120,19 @@ class ELQModel:
|
|
|
107
120
|
"""Take data inputs and extract relevant properties."""
|
|
108
121
|
self.form = {}
|
|
109
122
|
self.transform = {}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
for key, component in self.components.items():
|
|
124
|
+
|
|
125
|
+
if "background" in key:
|
|
126
|
+
self.form["bg"] = "B_bg"
|
|
127
|
+
self.transform["bg"] = False
|
|
128
|
+
if re.match("source", key):
|
|
129
|
+
source_component_map = component.map
|
|
130
|
+
self.transform[source_component_map["source"]] = False
|
|
131
|
+
self.form[source_component_map["source"]] = source_component_map["coupling_matrix"]
|
|
132
|
+
if "offset" in key:
|
|
133
|
+
self.form["d"] = "B_d"
|
|
134
|
+
self.transform["d"] = False
|
|
135
|
+
|
|
121
136
|
self.components[key].initialise(self.sensor_object, self.meteorology, self.gas_species)
|
|
122
137
|
|
|
123
138
|
def to_mcmc(self):
|
|
@@ -175,6 +190,109 @@ class ELQModel:
|
|
|
175
190
|
for key in self.mcmc.store:
|
|
176
191
|
state[key] = self.mcmc.store[key]
|
|
177
192
|
|
|
193
|
+
self.make_combined_source_model()
|
|
194
|
+
|
|
195
|
+
def make_combined_source_model(self):
|
|
196
|
+
"""Aggregate multiple individual source models into a single combined source model.
|
|
197
|
+
|
|
198
|
+
This function iterates through the existing source models stored in `self.components` and consolidates them
|
|
199
|
+
into a unified source model named `"sources_combined"`. This is particularly useful when multiple source
|
|
200
|
+
models are involved in an analysis, and a merged representation is required for visualization.
|
|
201
|
+
|
|
202
|
+
The combined source model is created as an instance of the `Normal` model, with the label string
|
|
203
|
+
"sources_combined" with the following attributes:
|
|
204
|
+
- emission_rate: concatenated across all source models.
|
|
205
|
+
- all_source_locations: concatenated across all source models.
|
|
206
|
+
- number_on_sources: derived by summing the individual source counts across all source models
|
|
207
|
+
- label_string: concatenated across all source models.
|
|
208
|
+
- individual_source_labels: concatenated across all source models.
|
|
209
|
+
|
|
210
|
+
Once combined, the `"sources_combined"` model is stored in the `self.components` dictionary for later use.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If the reference locations of the individual source models are inconsistent.
|
|
214
|
+
This is checked by comparing the reference latitude, longitude, and altitude of each source model.
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
combined_model = Normal(label_string="sources_combined")
|
|
218
|
+
emission_rate = np.empty((0, self.mcmc.n_iter))
|
|
219
|
+
all_source_locations_east = np.empty((0, self.mcmc.n_iter))
|
|
220
|
+
all_source_locations_north = np.empty((0, self.mcmc.n_iter))
|
|
221
|
+
all_source_locations_up = np.empty((0, self.mcmc.n_iter))
|
|
222
|
+
number_on_sources = np.empty((0, self.mcmc.n_iter))
|
|
223
|
+
label_string = []
|
|
224
|
+
individual_source_labels = []
|
|
225
|
+
|
|
226
|
+
ref_latitude = None
|
|
227
|
+
ref_longitude = None
|
|
228
|
+
ref_altitude = None
|
|
229
|
+
for key, component in self.components.items():
|
|
230
|
+
if re.match("source", key):
|
|
231
|
+
comp_ref_latitude = component.all_source_locations.ref_latitude
|
|
232
|
+
comp_ref_longitude = component.all_source_locations.ref_longitude
|
|
233
|
+
comp_ref_altitude = component.all_source_locations.ref_altitude
|
|
234
|
+
if ref_latitude is None and ref_longitude is None and ref_altitude is None:
|
|
235
|
+
ref_latitude = comp_ref_latitude
|
|
236
|
+
ref_longitude = comp_ref_longitude
|
|
237
|
+
ref_altitude = comp_ref_altitude
|
|
238
|
+
else:
|
|
239
|
+
if (
|
|
240
|
+
not np.isclose(ref_latitude, comp_ref_latitude)
|
|
241
|
+
or not np.isclose(ref_longitude, comp_ref_longitude)
|
|
242
|
+
or not np.isclose(ref_altitude, comp_ref_altitude)
|
|
243
|
+
):
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"Inconsistent reference locations in component '{key}'. "
|
|
246
|
+
"All source models must share the same reference location."
|
|
247
|
+
)
|
|
248
|
+
emission_rate = np.concatenate((emission_rate, component.emission_rate))
|
|
249
|
+
number_on_sources = np.concatenate(
|
|
250
|
+
(
|
|
251
|
+
number_on_sources.reshape((-1, self.mcmc.n_iter)),
|
|
252
|
+
component.number_on_sources.reshape(-1, self.mcmc.n_iter),
|
|
253
|
+
),
|
|
254
|
+
axis=0,
|
|
255
|
+
)
|
|
256
|
+
label_string.append(component.label_string)
|
|
257
|
+
individual_source_labels.append(component.individual_source_labels)
|
|
258
|
+
|
|
259
|
+
all_source_locations_east = np.concatenate(
|
|
260
|
+
(
|
|
261
|
+
all_source_locations_east,
|
|
262
|
+
component.all_source_locations.east.reshape((-1, self.mcmc.n_iter)),
|
|
263
|
+
),
|
|
264
|
+
axis=0,
|
|
265
|
+
)
|
|
266
|
+
all_source_locations_north = np.concatenate(
|
|
267
|
+
(
|
|
268
|
+
all_source_locations_north,
|
|
269
|
+
component.all_source_locations.north.reshape((-1, self.mcmc.n_iter)),
|
|
270
|
+
),
|
|
271
|
+
axis=0,
|
|
272
|
+
)
|
|
273
|
+
all_source_locations_up = np.concatenate(
|
|
274
|
+
(
|
|
275
|
+
all_source_locations_up,
|
|
276
|
+
component.all_source_locations.up.reshape((-1, self.mcmc.n_iter)),
|
|
277
|
+
),
|
|
278
|
+
axis=0,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
combined_model.all_source_locations = ENU(
|
|
282
|
+
ref_altitude=ref_altitude,
|
|
283
|
+
ref_latitude=ref_latitude,
|
|
284
|
+
ref_longitude=ref_longitude,
|
|
285
|
+
east=all_source_locations_east,
|
|
286
|
+
north=all_source_locations_north,
|
|
287
|
+
up=all_source_locations_up,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
combined_model.emission_rate = emission_rate
|
|
291
|
+
combined_model.label_string = label_string
|
|
292
|
+
combined_model.number_on_sources = np.sum(number_on_sources, axis=0)
|
|
293
|
+
combined_model.individual_source_labels = [item for sublist in individual_source_labels for item in sublist]
|
|
294
|
+
self.components["sources_combined"] = combined_model
|
|
295
|
+
|
|
178
296
|
def plot_log_posterior(self, burn_in_value: int, plot: Plot = Plot()) -> Plot():
|
|
179
297
|
"""Plots the trace of the log posterior over the iterations of the MCMC.
|
|
180
298
|
|
pyelq/plotting/plot.py
CHANGED
|
@@ -9,6 +9,7 @@ Large module containing all the plotting code used to create various plots. Cont
|
|
|
9
9
|
definition.
|
|
10
10
|
|
|
11
11
|
"""
|
|
12
|
+
import re
|
|
12
13
|
import warnings
|
|
13
14
|
from copy import deepcopy
|
|
14
15
|
from dataclasses import dataclass, field
|
|
@@ -16,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Callable, Type, Union
|
|
|
16
17
|
|
|
17
18
|
import numpy as np
|
|
18
19
|
import pandas as pd
|
|
20
|
+
import plotly.express as px
|
|
19
21
|
import plotly.figure_factory as ff
|
|
20
22
|
import plotly.graph_objects as go
|
|
21
23
|
from geojson import Feature, FeatureCollection
|
|
@@ -38,6 +40,9 @@ from pyelq.support_functions.post_processing import (
|
|
|
38
40
|
if TYPE_CHECKING:
|
|
39
41
|
from pyelq.model import ELQModel
|
|
40
42
|
|
|
43
|
+
RGB_LIGHT_BLUE = "rgb(102, 197, 204)"
|
|
44
|
+
MCMC_ITERATION_NUMBER_LITERAL = "MCMC Iteration Number"
|
|
45
|
+
|
|
41
46
|
|
|
42
47
|
def lighter_rgb(rbg_string: str) -> str:
|
|
43
48
|
"""Takes in an RGB string and returns a lighter version of this colour.
|
|
@@ -162,16 +167,9 @@ def create_trace_specifics(object_to_plot: Union[Type[SlabAndSpike], SourceModel
|
|
|
162
167
|
if isinstance(object_to_plot, SourceModel):
|
|
163
168
|
dict_key = kwargs.pop("dict_key", "number_of_sources_plot")
|
|
164
169
|
title_text = "Number of Sources 'on' against MCMC iterations"
|
|
165
|
-
x_label =
|
|
170
|
+
x_label = MCMC_ITERATION_NUMBER_LITERAL
|
|
166
171
|
y_label = "Number of Sources 'on'"
|
|
167
|
-
|
|
168
|
-
if isinstance(object_to_plot, SlabAndSpike):
|
|
169
|
-
total_nof_sources = emission_rates.shape[0]
|
|
170
|
-
y_values = total_nof_sources - np.sum(object_to_plot.allocation, axis=0)
|
|
171
|
-
elif object_to_plot.reversible_jump:
|
|
172
|
-
y_values = np.count_nonzero(np.logical_not(np.isnan(emission_rates)), axis=0)
|
|
173
|
-
else:
|
|
174
|
-
raise TypeError("No plotting routine implemented for this SourceModel type.")
|
|
172
|
+
y_values = object_to_plot.number_on_sources
|
|
175
173
|
x_values = np.array(range(y_values.size))
|
|
176
174
|
color = "rgb(248, 156, 116)"
|
|
177
175
|
name = "Number of Sources 'on'"
|
|
@@ -179,11 +177,11 @@ def create_trace_specifics(object_to_plot: Union[Type[SlabAndSpike], SourceModel
|
|
|
179
177
|
elif isinstance(object_to_plot, MCMC):
|
|
180
178
|
dict_key = kwargs.pop("dict_key", "log_posterior_plot")
|
|
181
179
|
title_text = "Log posterior values against MCMC iterations"
|
|
182
|
-
x_label =
|
|
180
|
+
x_label = MCMC_ITERATION_NUMBER_LITERAL
|
|
183
181
|
y_label = "Log Posterior<br>Value"
|
|
184
182
|
y_values = object_to_plot.store["log_post"].flatten()
|
|
185
183
|
x_values = np.array(range(y_values.size))
|
|
186
|
-
color =
|
|
184
|
+
color = RGB_LIGHT_BLUE
|
|
187
185
|
name = "Log Posterior"
|
|
188
186
|
|
|
189
187
|
if "burn_in" not in kwargs:
|
|
@@ -245,7 +243,7 @@ def create_plot_specifics(
|
|
|
245
243
|
if plot_type == "line":
|
|
246
244
|
dict_key = kwargs.pop("dict_key", "error_model_iterations")
|
|
247
245
|
title_text = "Estimated Error Model Values"
|
|
248
|
-
x_label =
|
|
246
|
+
x_label = MCMC_ITERATION_NUMBER_LITERAL
|
|
249
247
|
y_label = "Estimated Error Model<br>Standard Deviation (ppm)"
|
|
250
248
|
|
|
251
249
|
elif plot_type == "box":
|
|
@@ -270,7 +268,7 @@ def create_plot_specifics(
|
|
|
270
268
|
if plot_type == "line":
|
|
271
269
|
dict_key = kwargs.pop("dict_key", "offset_iterations")
|
|
272
270
|
title_text = f"Estimated Value of Offset w.r.t. {offset_sensor_name}"
|
|
273
|
-
x_label =
|
|
271
|
+
x_label = MCMC_ITERATION_NUMBER_LITERAL
|
|
274
272
|
y_label = "Estimated Offset<br>Value (ppm)"
|
|
275
273
|
|
|
276
274
|
elif plot_type == "box":
|
|
@@ -816,7 +814,7 @@ class Plot:
|
|
|
816
814
|
|
|
817
815
|
dict_key = "estimated_values_plot"
|
|
818
816
|
title_text = "Estimated Values of Sources With Respect to MCMC Iterations"
|
|
819
|
-
x_label =
|
|
817
|
+
x_label = MCMC_ITERATION_NUMBER_LITERAL
|
|
820
818
|
y_label = "Estimated Emission<br>Values (kg/hr)"
|
|
821
819
|
|
|
822
820
|
fig = go.Figure()
|
|
@@ -833,13 +831,17 @@ class Plot:
|
|
|
833
831
|
|
|
834
832
|
for source_idx in range(source_model_object.emission_rate.shape[0]):
|
|
835
833
|
y_values = source_model_object.emission_rate[source_idx, :]
|
|
834
|
+
if source_model_object.individual_source_labels[source_idx] is not None:
|
|
835
|
+
source_label = source_model_object.individual_source_labels[source_idx]
|
|
836
|
+
else:
|
|
837
|
+
source_label = f"Source {source_idx}"
|
|
836
838
|
|
|
837
839
|
fig = plot_single_scatter(
|
|
838
840
|
fig=fig,
|
|
839
841
|
x_values=x_values,
|
|
840
842
|
y_values=y_values,
|
|
841
|
-
color=
|
|
842
|
-
name=
|
|
843
|
+
color=RGB_LIGHT_BLUE,
|
|
844
|
+
name=source_label,
|
|
843
845
|
burn_in=burn_in,
|
|
844
846
|
show_legend=False,
|
|
845
847
|
legend_group="Source traces",
|
|
@@ -849,7 +851,7 @@ class Plot:
|
|
|
849
851
|
fig=fig,
|
|
850
852
|
x_values=np.array([None]),
|
|
851
853
|
y_values=np.array([None]),
|
|
852
|
-
color=
|
|
854
|
+
color=RGB_LIGHT_BLUE,
|
|
853
855
|
name="Source traces",
|
|
854
856
|
burn_in=0,
|
|
855
857
|
show_legend=True,
|
|
@@ -970,6 +972,7 @@ class Plot:
|
|
|
970
972
|
def plot_quantification_results_on_map(
|
|
971
973
|
self,
|
|
972
974
|
model_object: "ELQModel",
|
|
975
|
+
source_model_to_plot_key: str = None,
|
|
973
976
|
bin_size_x: float = 1,
|
|
974
977
|
bin_size_y: float = 1,
|
|
975
978
|
normalized_count_limit: float = 0.005,
|
|
@@ -978,12 +981,14 @@ class Plot:
|
|
|
978
981
|
):
|
|
979
982
|
"""Function to create a map with the quantification results of the model object.
|
|
980
983
|
|
|
981
|
-
This function takes the
|
|
982
|
-
populates the figure dictionary with three different maps showing the normalized count,
|
|
983
|
-
and the inter-quartile range of the emission rate estimates.
|
|
984
|
+
This function takes the "SourceModel" object and calculates the statistics for the quantification results.
|
|
985
|
+
It then populates the figure dictionary with three different maps showing the normalized count,
|
|
986
|
+
median emission rate and the inter-quartile range of the emission rate estimates.
|
|
984
987
|
|
|
985
988
|
Args:
|
|
986
989
|
model_object (ELQModel): ELQModel object containing the quantification results
|
|
990
|
+
source_model_to_plot_key (str, optional): Key to use in the model_object.components dictionary to access
|
|
991
|
+
the SourceModel object. If None, defaults to "sources_combined".
|
|
987
992
|
bin_size_x (float, optional): Size of the bins in the x-direction. Defaults to 1.
|
|
988
993
|
bin_size_y (float, optional): Size of the bins in the y-direction. Defaults to 1.
|
|
989
994
|
normalized_count_limit (float, optional): Limit for the normalized count to show on the map.
|
|
@@ -993,16 +998,26 @@ class Plot:
|
|
|
993
998
|
show_summary_results (bool, optional): Flag to show the summary results on the map. Defaults to True.
|
|
994
999
|
|
|
995
1000
|
"""
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1001
|
+
if source_model_to_plot_key is None:
|
|
1002
|
+
source_model_to_plot_key = "sources_combined"
|
|
1003
|
+
|
|
1004
|
+
source_model = model_object.components[source_model_to_plot_key]
|
|
1005
|
+
sensor_object = model_object.sensor_object
|
|
1006
|
+
|
|
1007
|
+
source_locations = source_model.all_source_locations
|
|
1008
|
+
emission_rates = source_model.emission_rate
|
|
999
1009
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1010
|
+
ref_latitude = source_locations.ref_latitude
|
|
1011
|
+
ref_longitude = source_locations.ref_longitude
|
|
1012
|
+
ref_altitude = source_locations.ref_altitude
|
|
1013
|
+
|
|
1014
|
+
datetime_min_string = sensor_object.time.min().strftime("%d-%b-%Y, %H:%M:%S")
|
|
1015
|
+
datetime_max_string = sensor_object.time.max().strftime("%d-%b-%Y, %H:%M:%S")
|
|
1002
1016
|
|
|
1003
1017
|
result_weighted, _, normalized_count, count_boolean, enu_points, summary_result = (
|
|
1004
1018
|
calculate_rectangular_statistics(
|
|
1005
|
-
|
|
1019
|
+
emission_rates=emission_rates,
|
|
1020
|
+
source_locations=source_locations,
|
|
1006
1021
|
bin_size_x=bin_size_x,
|
|
1007
1022
|
bin_size_y=bin_size_y,
|
|
1008
1023
|
burn_in=burn_in,
|
|
@@ -1040,7 +1055,7 @@ class Plot:
|
|
|
1040
1055
|
font_family="Futura",
|
|
1041
1056
|
font_size=15,
|
|
1042
1057
|
)
|
|
1043
|
-
|
|
1058
|
+
sensor_object.plot_sensor_location(self.figure_dict["count_map"])
|
|
1044
1059
|
self.figure_dict["count_map"].update_traces(showlegend=False)
|
|
1045
1060
|
|
|
1046
1061
|
adjusted_result_weights = result_weighted.copy()
|
|
@@ -1066,7 +1081,7 @@ class Plot:
|
|
|
1066
1081
|
font_family="Futura",
|
|
1067
1082
|
font_size=15,
|
|
1068
1083
|
)
|
|
1069
|
-
|
|
1084
|
+
sensor_object.plot_sensor_location(self.figure_dict["median_map"])
|
|
1070
1085
|
self.figure_dict["median_map"].update_traces(showlegend=False)
|
|
1071
1086
|
|
|
1072
1087
|
iqr_of_all_emissions = np.nanquantile(a=adjusted_result_weights, q=0.75, axis=2) - np.nanquantile(
|
|
@@ -1091,9 +1106,41 @@ class Plot:
|
|
|
1091
1106
|
font_family="Futura",
|
|
1092
1107
|
font_size=15,
|
|
1093
1108
|
)
|
|
1094
|
-
|
|
1109
|
+
sensor_object.plot_sensor_location(self.figure_dict["iqr_map"])
|
|
1095
1110
|
self.figure_dict["iqr_map"].update_traces(showlegend=False)
|
|
1096
1111
|
|
|
1112
|
+
colormap_fixed = px.colors.qualitative.G10
|
|
1113
|
+
marker_dict = {"size": 10, "opacity": 0.8}
|
|
1114
|
+
for key, _ in model_object.components.items():
|
|
1115
|
+
if bool(re.search("fixed", key)):
|
|
1116
|
+
source_model_fixed = model_object.components[key]
|
|
1117
|
+
source_locations_fixed = source_model_fixed.all_source_locations
|
|
1118
|
+
source_location_fixed_lla = source_locations_fixed.to_lla()
|
|
1119
|
+
source_location_fixed_average = LLA(
|
|
1120
|
+
latitude=np.nanmean(source_location_fixed_lla.latitude, axis=1),
|
|
1121
|
+
longitude=np.nanmean(source_location_fixed_lla.longitude, axis=1),
|
|
1122
|
+
altitude=np.nanmean(source_location_fixed_lla.altitude, axis=1),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
for lat_fixed, lon_fixed, label_fixed in zip(
|
|
1126
|
+
source_location_fixed_average.latitude,
|
|
1127
|
+
source_location_fixed_average.longitude,
|
|
1128
|
+
source_model_fixed.individual_source_labels,
|
|
1129
|
+
):
|
|
1130
|
+
color_idx = source_model_fixed.individual_source_labels.index(label_fixed)
|
|
1131
|
+
marker_dict["color"] = colormap_fixed[color_idx % len(colormap_fixed)]
|
|
1132
|
+
|
|
1133
|
+
fixed_source_location_trace = go.Scattermap(
|
|
1134
|
+
mode="markers",
|
|
1135
|
+
lon=np.array(lon_fixed),
|
|
1136
|
+
lat=np.array(lat_fixed),
|
|
1137
|
+
name=label_fixed,
|
|
1138
|
+
marker=marker_dict,
|
|
1139
|
+
)
|
|
1140
|
+
self.figure_dict["count_map"].add_trace(fixed_source_location_trace)
|
|
1141
|
+
self.figure_dict["median_map"].add_trace(fixed_source_location_trace)
|
|
1142
|
+
self.figure_dict["iqr_map"].add_trace(fixed_source_location_trace)
|
|
1143
|
+
|
|
1097
1144
|
if show_summary_results:
|
|
1098
1145
|
self.figure_dict["count_map"].add_trace(summary_trace)
|
|
1099
1146
|
self.figure_dict["count_map"].update_traces(showlegend=True)
|
|
@@ -52,7 +52,8 @@ def is_regularly_spaced(array: np.ndarray, tolerance: float = 0.01, return_delta
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def calculate_rectangular_statistics(
|
|
55
|
-
|
|
55
|
+
emission_rates: np.ndarray,
|
|
56
|
+
source_locations: ENU,
|
|
56
57
|
bin_size_x: float = 1,
|
|
57
58
|
bin_size_y: float = 1,
|
|
58
59
|
burn_in: int = 0,
|
|
@@ -70,7 +71,10 @@ def calculate_rectangular_statistics(
|
|
|
70
71
|
likelihood of the blob.
|
|
71
72
|
|
|
72
73
|
Args:
|
|
73
|
-
|
|
74
|
+
emission_rates (np.ndarray): and array of shape (number_of_sources, number_of_iterations)
|
|
75
|
+
containing emission rate estimates from the MCMC run.
|
|
76
|
+
source_locations (ENU): An object containing the east, north, and up coordinates of source locations,
|
|
77
|
+
as well as reference latitude, longitude, and altitude.
|
|
74
78
|
bin_size_x (float, optional): Size of the bins in the x-direction. Defaults to 1.
|
|
75
79
|
bin_size_y (float, optional): Size of the bins in the y-direction. Defaults to 1.
|
|
76
80
|
burn_in (int, optional): Number of burn-in iterations used in the MCMC. Defaults to 0.
|
|
@@ -85,24 +89,9 @@ def calculate_rectangular_statistics(
|
|
|
85
89
|
summary_result (pd.DataFrame): Summary statistics for each blob of estimates.
|
|
86
90
|
|
|
87
91
|
"""
|
|
88
|
-
nof_iterations =
|
|
89
|
-
ref_latitude = model_object.components["source"].dispersion_model.source_map.location.ref_latitude
|
|
90
|
-
ref_longitude = model_object.components["source"].dispersion_model.source_map.location.ref_longitude
|
|
91
|
-
ref_altitude = model_object.components["source"].dispersion_model.source_map.location.ref_altitude
|
|
92
|
-
|
|
93
|
-
if model_object.components["source"].reversible_jump:
|
|
94
|
-
all_source_locations = model_object.mcmc.store["z_src"]
|
|
95
|
-
else:
|
|
96
|
-
source_locations = (
|
|
97
|
-
model_object.components["source"]
|
|
98
|
-
.dispersion_model.source_map.location.to_enu(
|
|
99
|
-
ref_longitude=ref_longitude, ref_latitude=ref_latitude, ref_altitude=ref_altitude
|
|
100
|
-
)
|
|
101
|
-
.to_array()
|
|
102
|
-
)
|
|
103
|
-
all_source_locations = np.repeat(source_locations.T[:, :, np.newaxis], model_object.mcmc.n_iter, axis=2)
|
|
92
|
+
nof_iterations = emission_rates.shape[1]
|
|
104
93
|
|
|
105
|
-
if np.all(np.isnan(
|
|
94
|
+
if np.all(np.isnan(source_locations.east)):
|
|
106
95
|
warnings.warn("No sources found")
|
|
107
96
|
result_weighted = np.array([[[np.nan]]])
|
|
108
97
|
overall_count = np.array([[0]])
|
|
@@ -113,10 +102,10 @@ def calculate_rectangular_statistics(
|
|
|
113
102
|
|
|
114
103
|
return result_weighted, overall_count, normalized_count, count_boolean, edges_result[:2], summary_result
|
|
115
104
|
|
|
116
|
-
min_x = np.nanmin(
|
|
117
|
-
max_x = np.nanmax(
|
|
118
|
-
min_y = np.nanmin(
|
|
119
|
-
max_y = np.nanmax(
|
|
105
|
+
min_x = np.nanmin(source_locations.east)
|
|
106
|
+
max_x = np.nanmax(source_locations.east)
|
|
107
|
+
min_y = np.nanmin(source_locations.north)
|
|
108
|
+
max_y = np.nanmax(source_locations.north)
|
|
120
109
|
|
|
121
110
|
bin_min_x = np.floor(min_x - 0.1)
|
|
122
111
|
bin_max_x = np.ceil(max_x + 0.1)
|
|
@@ -125,19 +114,20 @@ def calculate_rectangular_statistics(
|
|
|
125
114
|
bin_min_iteration = burn_in + 0.5
|
|
126
115
|
bin_max_iteration = nof_iterations + 0.5
|
|
127
116
|
|
|
128
|
-
max_nof_sources =
|
|
117
|
+
max_nof_sources = source_locations.east.shape[0]
|
|
129
118
|
|
|
130
119
|
x_edges = np.arange(start=bin_min_x, stop=bin_max_x + bin_size_x, step=bin_size_x)
|
|
131
120
|
y_edges = np.arange(start=bin_min_y, stop=bin_max_y + bin_size_y, step=bin_size_y)
|
|
132
121
|
iteration_edges = np.arange(start=bin_min_iteration, stop=bin_max_iteration + bin_size_y, step=1)
|
|
133
122
|
|
|
134
|
-
result_x_vals =
|
|
135
|
-
result_y_vals =
|
|
136
|
-
result_z_vals =
|
|
123
|
+
result_x_vals = source_locations.east.flatten()
|
|
124
|
+
result_y_vals = source_locations.north.flatten()
|
|
125
|
+
result_z_vals = source_locations.up.flatten()
|
|
137
126
|
|
|
138
127
|
result_iteration_vals = np.array(range(nof_iterations)).reshape(1, -1) + 1
|
|
139
128
|
result_iteration_vals = np.tile(result_iteration_vals, (max_nof_sources, 1)).flatten()
|
|
140
|
-
|
|
129
|
+
|
|
130
|
+
results_estimates = emission_rates.flatten()
|
|
141
131
|
|
|
142
132
|
result_weighted, _ = np.histogramdd(
|
|
143
133
|
sample=np.array([result_x_vals, result_y_vals, result_iteration_vals]).T,
|
|
@@ -167,11 +157,10 @@ def calculate_rectangular_statistics(
|
|
|
167
157
|
x_edges=x_edges,
|
|
168
158
|
y_edges=y_edges,
|
|
169
159
|
nof_iterations=nof_iterations,
|
|
170
|
-
ref_latitude=ref_latitude,
|
|
171
|
-
ref_longitude=ref_longitude,
|
|
172
|
-
ref_altitude=ref_altitude,
|
|
160
|
+
ref_latitude=source_locations.ref_latitude,
|
|
161
|
+
ref_longitude=source_locations.ref_longitude,
|
|
162
|
+
ref_altitude=source_locations.ref_altitude,
|
|
173
163
|
)
|
|
174
|
-
|
|
175
164
|
return result_weighted, overall_count, normalized_count, count_boolean, edges_result[:2], summary_result
|
|
176
165
|
|
|
177
166
|
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyelq
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
4
4
|
Summary: Package for detection, localization and quantification code.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Keywords: gas dispersion,emission,detection,localization,quantification
|
|
7
7
|
Author: Bas van de Kerkhof
|
|
8
|
-
Requires-Python: >=3.9
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
9
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.9
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
16
|
Requires-Dist: geojson (>=3.2.0)
|
|
15
17
|
Requires-Dist: numpy (>=2.0.2)
|
|
16
|
-
Requires-Dist: openmcmc (==1.0.
|
|
18
|
+
Requires-Dist: openmcmc (==1.0.6)
|
|
17
19
|
Requires-Dist: pandas (>=2.2.3)
|
|
18
20
|
Requires-Dist: plotly (>=6.0.0)
|
|
19
21
|
Requires-Dist: pymap3d (>=3.1.0)
|
|
@@ -4,18 +4,18 @@ pyelq/component/background.py,sha256=kalBr0fck1ktDUoNlLNff805tM2yZI0TVHPbUL5uZ5s
|
|
|
4
4
|
pyelq/component/component.py,sha256=rOQSWhhnKnx8Vc5MevX9B1rt42UY9gabFKq_7cJFQO8,2360
|
|
5
5
|
pyelq/component/error_model.py,sha256=NLPKEuEPnb2DwaX78CibHy7ioMkljRzwT96Y8h6_sDc,16261
|
|
6
6
|
pyelq/component/offset.py,sha256=RPQLjdzvS-7Moy1u_wAF84DQEEn4MqfW6RMozwf-11g,7718
|
|
7
|
-
pyelq/component/source_model.py,sha256=
|
|
7
|
+
pyelq/component/source_model.py,sha256=eAAI8CDpd9Q474W8-1KA5t9-Zjoa2Jz-hwZ3asOxtLc,44873
|
|
8
8
|
pyelq/coordinate_system.py,sha256=UXk6GOghMxEE3NfWcGPSVsI1q89xEjHTe9sfHbh-gDc,22281
|
|
9
9
|
pyelq/data_access/__init__.py,sha256=hLTVYOMdmEVsckJ5OOCX9jf2Cqw5xRkQnmxCmYCGWXw,186
|
|
10
10
|
pyelq/data_access/data_access.py,sha256=mI2HYxsZCr4vmmV-t85HYlHuZ06GJEBX4ypx9Putou0,3973
|
|
11
11
|
pyelq/dispersion_model/__init__.py,sha256=KN1hyrEBsfoIHd0_u_BGy2n-KliiDr-_7YAHiNzhyT0,194
|
|
12
12
|
pyelq/dispersion_model/gaussian_plume.py,sha256=q19ZB0wJBEAcdTOYb5AUw85T7yGlM7lImXPc2Uh0Gfo,31404
|
|
13
|
-
pyelq/dlm.py,sha256=
|
|
13
|
+
pyelq/dlm.py,sha256=LnD3BpvYjuHw_MiZaFfamfyJMQTMxd0kay_Z7w_yWH0,25375
|
|
14
14
|
pyelq/gas_species.py,sha256=tQy41zINgI8Q1P1iHKQWU2X48FwtzYb-mCMdYT2yqOc,6908
|
|
15
15
|
pyelq/meteorology.py,sha256=INs_Y-SGVD27re2THTANsgnw81iu9olr-ozA4lnD08U,17433
|
|
16
|
-
pyelq/model.py,sha256=
|
|
16
|
+
pyelq/model.py,sha256=P3H_MzQRwQpsK1aW69NcwKNyTuft_MHhsc82uSox8s4,15252
|
|
17
17
|
pyelq/plotting/__init__.py,sha256=E3qUfLWIHlC11-P5GJKOkflKclZB8TzHpRnOZbg9swk,176
|
|
18
|
-
pyelq/plotting/plot.py,sha256=
|
|
18
|
+
pyelq/plotting/plot.py,sha256=T25IINoXjvG9CT7hulJvaV4L6_AzjnFDgR1q5ctBA_U,50555
|
|
19
19
|
pyelq/preprocessing.py,sha256=KDYrfOJSJHePWqdn4rWBum8AKLuO8FzaZBJjTB3uImY,12654
|
|
20
20
|
pyelq/sensor/__init__.py,sha256=RK00UUnv4z45_kAdFoSIUKD6WmzPbbqYOlbyGG-_ZLw,197
|
|
21
21
|
pyelq/sensor/beam.py,sha256=6E7-cH_IoBRgferIcyOWUfnFjnakhpTbnfWDAyABezA,1806
|
|
@@ -23,10 +23,10 @@ pyelq/sensor/satellite.py,sha256=2T6NcSPc_YxgnhyOTQz8zlz6IqWl6uSnDTFHGBoy0lI,231
|
|
|
23
23
|
pyelq/sensor/sensor.py,sha256=t7bqU3222BRS7hXs4pWeTdeB1IkKlFYH6VdNtDU2RbU,9183
|
|
24
24
|
pyelq/source_map.py,sha256=L82dvrZTpVQBy10BhoCJ09-gVO5d90DDwvIdIST9l4g,5741
|
|
25
25
|
pyelq/support_functions/__init__.py,sha256=ZYcVLitB51BXYojlt6FEZ4ciDFkRlA5ZkJvnzWQsdD4,229
|
|
26
|
-
pyelq/support_functions/post_processing.py,sha256=
|
|
26
|
+
pyelq/support_functions/post_processing.py,sha256=MBoe75SifPnsFyooA1oaF-waC-rVxtqEk-HiNjrJpQQ,16996
|
|
27
27
|
pyelq/support_functions/spatio_temporal_interpolation.py,sha256=sU_-9Yz4I1YgNa78_KvRclsaP4LlZiE0HG7_OZ1Vahk,10609
|
|
28
|
-
pyelq-1.1.
|
|
29
|
-
pyelq-1.1.
|
|
30
|
-
pyelq-1.1.
|
|
31
|
-
pyelq-1.1.
|
|
32
|
-
pyelq-1.1.
|
|
28
|
+
pyelq-1.1.3.dist-info/LICENSE.md,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
29
|
+
pyelq-1.1.3.dist-info/LICENSES/Apache-2.0.txt,sha256=B05uMshqTA74s-0ltyHKI6yoPfJ3zYgQbvcXfDVGFf8,10280
|
|
30
|
+
pyelq-1.1.3.dist-info/METADATA,sha256=fEhik5aHpYSq4-HtJNvYa_f7GXFzN1aOuhqUf7ppjzY,8515
|
|
31
|
+
pyelq-1.1.3.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
32
|
+
pyelq-1.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|