pyelq 1.1.0__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.
@@ -0,0 +1,875 @@
1
+ # SPDX-FileCopyrightText: 2024 Shell Global Solutions International B.V. All Rights Reserved.
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # -*- coding: utf-8 -*-
6
+ """Component Class and subclasses for source model.
7
+
8
+ A SourceModel instance inherits from 3 super-classes:
9
+ - Component: this is the general superclass for ELQModel components, which prototypes generic methods.
10
+ - A type of SourceGrouping: this class type implements an allocation of sources to different categories (e.g. slab
11
+ or spike), and sets up a sampler for estimating the classification of each source within the source map.
12
+ Inheriting from the NullGrouping class ensures that the allocation of all sources is fixed during the inversion,
13
+ and is not updated.
14
+ - A type of SourceDistribution: this class type implements a particular type of response distribution (mostly
15
+ Normal, but also allows for cases where we have e.g. exp(log_s) or a non-Gaussian prior).
16
+
17
+ """
18
+
19
+ from abc import abstractmethod
20
+ from copy import deepcopy
21
+ from dataclasses import dataclass, field
22
+ from typing import TYPE_CHECKING, Tuple, Union
23
+
24
+ import numpy as np
25
+ from openmcmc import parameter
26
+ from openmcmc.distribution.distribution import Categorical, Gamma, Poisson, Uniform
27
+ from openmcmc.distribution.location_scale import Normal as mcmcNormal
28
+ from openmcmc.model import Model
29
+ from openmcmc.sampler.metropolis_hastings import RandomWalkLoop
30
+ from openmcmc.sampler.reversible_jump import ReversibleJump
31
+ from openmcmc.sampler.sampler import MixtureAllocation, NormalGamma, NormalNormal
32
+
33
+ from pyelq.component.component import Component
34
+ from pyelq.coordinate_system import ENU
35
+ from pyelq.dispersion_model.gaussian_plume import GaussianPlume
36
+ from pyelq.gas_species import GasSpecies
37
+ from pyelq.meteorology import Meteorology
38
+ from pyelq.sensor.sensor import SensorGroup
39
+ from pyelq.source_map import SourceMap
40
+
41
+ if TYPE_CHECKING:
42
+ from pyelq.plotting.plot import Plot
43
+
44
+
45
+ @dataclass
46
+ class SourceGrouping:
47
+ """Superclass for source grouping approach.
48
+
49
+ Source grouping method determines the group allocation of each source in the model, e.g: slab and spike
50
+ distribution makes an on/off allocation for each source.
51
+
52
+ Attributes:
53
+ nof_sources (int): number of sources in the model.
54
+ 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
+
57
+ """
58
+
59
+ nof_sources: int = field(init=False)
60
+ emission_rate_mean: Union[float, np.ndarray] = field(init=False)
61
+ _source_key: str = field(init=False, default="s")
62
+
63
+ @abstractmethod
64
+ def make_allocation_model(self, model: list) -> list:
65
+ """Initialise the source allocation part of the model, and the parameters of the source response distribution.
66
+
67
+ Args:
68
+ model (list): overall model, consisting of list of distributions.
69
+
70
+ Returns:
71
+ list: overall model list, updated with allocation distribution.
72
+
73
+ """
74
+
75
+ @abstractmethod
76
+ def make_allocation_sampler(self, model: Model, sampler_list: list) -> list:
77
+ """Initialise the allocation part of the sampler.
78
+
79
+ Args:
80
+ model (Model): overall model, consisting of list of distributions.
81
+ sampler_list (list): list of samplers for individual parameters.
82
+
83
+ Returns:
84
+ list: sampler_list updated with sampler for the source allocation.
85
+
86
+ """
87
+
88
+ @abstractmethod
89
+ def make_allocation_state(self, state: dict) -> dict:
90
+ """Initialise the allocation part of the state.
91
+
92
+ Args:
93
+ state (dict): dictionary containing current state information.
94
+
95
+ Returns:
96
+ dict: state updated with parameters related to the source grouping.
97
+
98
+ """
99
+
100
+ @abstractmethod
101
+ def from_mcmc_group(self, store: dict):
102
+ """Extract posterior allocation samples from the MCMC sampler, attach them to the class.
103
+
104
+ Args:
105
+ store (dict): dictionary containing samples from the MCMC.
106
+
107
+ """
108
+
109
+
110
+ @dataclass
111
+ class NullGrouping(SourceGrouping):
112
+ """Null grouping: the grouping of the sources will not change during the course of the inversion.
113
+
114
+ Note that this is intended to support two distinct cases:
115
+ 1) The case where the source map is fixed, and a given prior mean and prior precision value are assigned to
116
+ each source (can be a common value for all sources, or can be a distinct allocation to each element of the
117
+ source map).
118
+ 2) The case where the dimensionality of the source map is changing during the inversion, and a common prior
119
+ mean and precision term are used for all sources.
120
+
121
+ """
122
+
123
+ def make_allocation_model(self, model: list) -> list:
124
+ """Initialise the source allocation part of the model.
125
+
126
+ In the NullGrouping case, the source allocation is fixed throughout, so this function does nothing (simply
127
+ returns the existing model un-modified).
128
+
129
+ Args:
130
+ model (list): model as constructed so far, consisting of list of distributions.
131
+
132
+ Returns:
133
+ list: overall model list, updated with allocation distribution.
134
+
135
+ """
136
+ return model
137
+
138
+ def make_allocation_sampler(self, model: Model, sampler_list: list) -> list:
139
+ """Initialise the allocation part of the sampler.
140
+
141
+ In the NullGrouping case, the source allocation is fixed throughout, so this function does nothing (simply
142
+ returns the existing sampler list un-modified).
143
+
144
+ Args:
145
+ model (Model): overall model set for the problem.
146
+ sampler_list (list): list of samplers for individual parameters.
147
+
148
+ Returns:
149
+ list: sampler_list updated with sampler for the source allocation.
150
+
151
+ """
152
+ return sampler_list
153
+
154
+ def make_allocation_state(self, state: dict) -> dict:
155
+ """Initialise the allocation part of the state.
156
+
157
+ The prior mean parameter and the fixed source allocation are added to the state.
158
+
159
+ Args:
160
+ state (dict): dictionary containing current state information.
161
+
162
+ Returns:
163
+ dict: state updated with parameters related to the source grouping.
164
+
165
+ """
166
+ state["mu_s"] = np.array(self.emission_rate_mean, ndmin=1)
167
+ state["alloc_s"] = np.zeros((self.nof_sources, 1), dtype="int")
168
+ return state
169
+
170
+ def from_mcmc_group(self, store: dict):
171
+ """Extract posterior allocation samples from the MCMC sampler, attach them to the class.
172
+
173
+ We have not implemented anything here as there is nothing to fetch from the MCMC solution here for the
174
+ NullGrouping Class.
175
+
176
+ Args:
177
+ store (dict): dictionary containing samples from the MCMC.
178
+
179
+ """
180
+
181
+
182
+ @dataclass
183
+ class SlabAndSpike(SourceGrouping):
184
+ """Slab and spike source model, special case for the source grouping.
185
+
186
+ Slab and spike: the prior for the emission rates is a two-component mixture, and the allocation is to be
187
+ estimated as part of the inversion.
188
+
189
+ Attributes:
190
+ slab_probability (float): prior probability of allocation to the slab component. Defaults to 0.05.
191
+ allocation (np.ndarray): set of allocation samples, with shape=(n_sources, n_iterations). Attached to
192
+ the class by self.from_mcmc_group().
193
+
194
+ """
195
+
196
+ slab_probability: float = 0.05
197
+ allocation: np.ndarray = field(init=False)
198
+
199
+ def make_allocation_model(self, model: list) -> list:
200
+ """Initialise the source allocation part of the model.
201
+
202
+ Args:
203
+ model (list): model as constructed so far, consisting of list of distributions.
204
+
205
+ Returns:
206
+ list: overall model list, updated with allocation distribution.
207
+
208
+ """
209
+ model.append(Categorical("alloc_s", prob="s_prob"))
210
+ return model
211
+
212
+ def make_allocation_sampler(self, model: Model, sampler_list: list) -> list:
213
+ """Initialise the allocation part of the sampler.
214
+
215
+ Args:
216
+ model (Model): overall model set for the problem.
217
+ sampler_list (list): list of samplers for individual parameters.
218
+
219
+ Returns:
220
+ list: sampler_list updated with sampler for the source allocation.
221
+
222
+ """
223
+ sampler_list.append(MixtureAllocation(param="alloc_s", model=model, response_param=self._source_key))
224
+ return sampler_list
225
+
226
+ def make_allocation_state(self, state: dict) -> dict:
227
+ """Initialise the allocation part of the state.
228
+
229
+ Args:
230
+ state (dict): dictionary containing current state information.
231
+
232
+ Returns:
233
+ dict: state updated with parameters related to the source grouping.
234
+
235
+ """
236
+ state["mu_s"] = np.array(self.emission_rate_mean, ndmin=1)
237
+ state["s_prob"] = np.tile(np.array([self.slab_probability, 1 - self.slab_probability]), (self.nof_sources, 1))
238
+ state["alloc_s"] = np.ones((self.nof_sources, 1), dtype="int")
239
+ return state
240
+
241
+ def from_mcmc_group(self, store: dict):
242
+ """Extract posterior allocation samples from the MCMC sampler, attach them to the class.
243
+
244
+ Args:
245
+ store (dict): dictionary containing samples from the MCMC.
246
+
247
+ """
248
+ self.allocation = store["alloc_s"]
249
+
250
+
251
+ @dataclass
252
+ class SourceDistribution:
253
+ """Superclass for source emission rate distribution.
254
+
255
+ Source distribution determines the type of prior to be used for the source emission rates, and the transformation
256
+ linking the source parameters and the data.
257
+
258
+ Elements related to transformation of source parameters are also specified at the model level.
259
+
260
+ Attributes:
261
+ nof_sources (int): number of sources in the model.
262
+ emission_rate (np.ndarray): set of emission rate samples, with shape=(n_sources, n_iterations). Attached to
263
+ the class by self.from_mcmc_dist().
264
+
265
+ """
266
+
267
+ nof_sources: int = field(init=False)
268
+ emission_rate: np.ndarray = field(init=False)
269
+
270
+ @abstractmethod
271
+ def make_source_model(self, model: list) -> list:
272
+ """Add distributional component to the overall model corresponding to the source emission rate distribution.
273
+
274
+ Args:
275
+ model (list): model as constructed so far, consisting of list of distributions.
276
+
277
+ Returns:
278
+ list: overall model list, updated with distributions related to source prior.
279
+
280
+ """
281
+
282
+ @abstractmethod
283
+ def make_source_sampler(self, model: Model, sampler_list: list) -> list:
284
+ """Initialise the source prior distribution part of the sampler.
285
+
286
+ Args:
287
+ model (Model): overall model set for the problem.
288
+ sampler_list (list): list of samplers for individual parameters.
289
+
290
+ Returns:
291
+ list: sampler_list updated with sampler for the emission rate parameters.
292
+
293
+ """
294
+
295
+ @abstractmethod
296
+ def make_source_state(self, state: dict) -> dict:
297
+ """Initialise the emission rate parts of the state.
298
+
299
+ Args:
300
+ state (dict): dictionary containing current state information.
301
+
302
+ Returns:
303
+ dict: state updated with parameters related to the source emission rates.
304
+
305
+ """
306
+
307
+ @abstractmethod
308
+ def from_mcmc_dist(self, store: dict):
309
+ """Extract posterior emission rate samples from the MCMC, attach them to the class.
310
+
311
+ Args:
312
+ store (dict): dictionary containing samples from the MCMC.
313
+
314
+ """
315
+
316
+
317
+ @dataclass
318
+ class NormalResponse(SourceDistribution):
319
+ """(Truncated) Gaussian prior for sources.
320
+
321
+ No transformation applied to parameters, i.e.:
322
+ - Prior distribution: s ~ N(mu, 1/precision)
323
+ - Likelihood contribution: y = A*s + b + ...
324
+
325
+ Attributes:
326
+ truncation (bool): indication of whether the emission rate prior should be truncated at 0. Defaults to True.
327
+ emission_rate_lb (Union[float, np.ndarray]): lower bound for the source emission rates. Defaults to 0.
328
+ emission_rate_mean (Union[float, np.ndarray]): prior mean for the emission rate distribution. Defaults to 0.
329
+
330
+ """
331
+
332
+ truncation: bool = True
333
+ emission_rate_lb: Union[float, np.ndarray] = 0
334
+ emission_rate_mean: Union[float, np.ndarray] = 0
335
+
336
+ def make_source_model(self, model: list) -> list:
337
+ """Add distributional component to the overall model corresponding to the source emission rate distribution.
338
+
339
+ Args:
340
+ model (list): model as constructed so far, consisting of list of distributions.
341
+
342
+ Returns:
343
+ list: model, updated with distributions related to source prior.
344
+
345
+ """
346
+ domain_response_lower = None
347
+ if self.truncation:
348
+ domain_response_lower = self.emission_rate_lb
349
+
350
+ model.append(
351
+ mcmcNormal(
352
+ "s",
353
+ mean=parameter.MixtureParameterVector(param="mu_s", allocation="alloc_s"),
354
+ precision=parameter.MixtureParameterMatrix(param="lambda_s", allocation="alloc_s"),
355
+ domain_response_lower=domain_response_lower,
356
+ )
357
+ )
358
+ return model
359
+
360
+ def make_source_sampler(self, model: Model, sampler_list: list = None) -> list:
361
+ """Initialise the source prior distribution part of the sampler.
362
+
363
+ Args:
364
+ model (Model): overall model set for the problem.
365
+ sampler_list (list): list of samplers for individual parameters.
366
+
367
+ Returns:
368
+ list: sampler_list updated with sampler for the emission rate parameters.
369
+
370
+ """
371
+ if sampler_list is None:
372
+ sampler_list = []
373
+ sampler_list.append(NormalNormal("s", model))
374
+ return sampler_list
375
+
376
+ def make_source_state(self, state: dict) -> dict:
377
+ """Initialise the emission rate part of the state.
378
+
379
+ Args:
380
+ state (dict): dictionary containing current state information.
381
+
382
+ Returns:
383
+ dict: state updated with initial emission rate vector.
384
+
385
+ """
386
+ state["s"] = np.zeros((self.nof_sources, 1))
387
+ return state
388
+
389
+ def from_mcmc_dist(self, store: dict):
390
+ """Extract posterior emission rate samples from the MCMC sampler, attach them to the class.
391
+
392
+ Args:
393
+ store (dict): dictionary containing samples from the MCMC.
394
+
395
+ """
396
+ self.emission_rate = store["s"]
397
+
398
+
399
+ @dataclass
400
+ class SourceModel(Component, SourceGrouping, SourceDistribution):
401
+ """Superclass for the specification of the source model in an inversion run.
402
+
403
+ Various different types of model. A SourceModel is an optional component of a model, and thus inherits
404
+ from Component.
405
+
406
+ A subclass instance of SourceModel must inherit from:
407
+ - an INSTANCE of SourceDistribution, which specifies a prior emission rate distribution for all sources in the
408
+ source map.
409
+ - an INSTANCE of SourceGrouping, which specifies a type of mixture prior specification for the sources (for
410
+ which the allocation is to be estimated as part of the inversion).
411
+
412
+ If the flag reversible_jump == True, then the number of sources and their locations are also estimated as part of
413
+ the inversion, in addition to the emission rates. If this flag is set to true, the sensor_object, meteorology and
414
+ gas_species objects are all attached to the class, as they will be required in the repeated computation of updates
415
+ to the coupling matrix during the inversion.
416
+
417
+ Attributes:
418
+ dispersion_model (GaussianPlume): dispersion model used to generate the couplings between source locations and
419
+ sensor observations.
420
+ coupling (np.ndarray): coupling matrix generated using dispersion_model.
421
+
422
+ sensor_object (SensorGroup): stores sensor information for reversible jump coupling updates.
423
+ meteorology (MeteorologyGroup): stores meteorology information for reversible jump coupling updates.
424
+ gas_species (GasSpecies): stores gas species information for reversible jump coupling updates.
425
+
426
+ reversible_jump (bool): logical indicating whether the reversible jump algorithm for estimation of the number
427
+ of sources and their locations should be run. Defaults to False.
428
+ random_walk_step_size (np.ndarray): (3 x 1) array specifying the standard deviations of the distributions
429
+ from which the random walk sampler draws new source locations. Defaults to np.array([1.0, 1.0, 0.1]).
430
+ site_limits (np.ndarray): (3 x 2) array specifying the lower (column 0) and upper (column 1) limits of the
431
+ analysis site. Only relevant for cases where reversible_jump == True (where sources are free to move in
432
+ the solution).
433
+ rate_num_sources (int): specification for the parameter for the Poisson prior distribution for the total number
434
+ of sources. Only relevant for cases where reversible_jump == True (where the number of sources in the
435
+ solution can change).
436
+ n_sources_max (int): maximum number of sources that can feature in the solution. Only relevant for cases where
437
+ reversible_jump == True (where the number of sources in the solution can change).
438
+ emission_proposal_std (float): standard deviation of the truncated Gaussian distribution used to propose the
439
+ new source emission rate in case of a birth move.
440
+
441
+ update_precision (bool): logical indicating whether the prior precision parameter for emission rates should be
442
+ updated as part of the inversion. Defaults to false.
443
+ prior_precision_shape (Union[float, np.ndarray]): shape parameters for the prior Gamma distribution for the
444
+ source precision parameter.
445
+ prior_precision_rate (Union[float, np.ndarray]): rate parameters for the prior Gamma distribution for the
446
+ source precision parameter.
447
+ initial_precision (Union[float, np.ndarray]): initial value for the source emission rate precision parameter.
448
+ precision_scalar (np.ndarray): precision values generated by MCMC inversion.
449
+
450
+ coverage_detection (float): sensor detection threshold (in ppm) to be used for coverage calculations.
451
+ coverage_test_source (float): test source (in kg/hr) which we wish to be able to see in coverage calculation.
452
+
453
+ threshold_function (Callable): Callable function which returns a single value that defines the threshold
454
+ for the coupling in a lambda function form. Examples: lambda x: np.quantile(x, 0.95, axis=0),
455
+ lambda x: np.max(x, axis=0), lambda x: np.mean(x, axis=0). Defaults to np.quantile.
456
+
457
+ """
458
+
459
+ dispersion_model: GaussianPlume = field(init=False, default=None)
460
+ coupling: np.ndarray = field(init=False)
461
+
462
+ sensor_object: SensorGroup = field(init=False, default=None)
463
+ meteorology: Meteorology = field(init=False, default=None)
464
+ gas_species: GasSpecies = field(init=False, default=None)
465
+
466
+ reversible_jump: bool = False
467
+ random_walk_step_size: np.ndarray = field(default_factory=lambda: np.array([1.0, 1.0, 0.1], ndmin=2).T)
468
+ site_limits: np.ndarray = None
469
+ rate_num_sources: int = 5
470
+ n_sources_max: int = 20
471
+ emission_proposal_std: float = 0.5
472
+
473
+ update_precision: bool = False
474
+ prior_precision_shape: Union[float, np.ndarray] = 1e-3
475
+ prior_precision_rate: Union[float, np.ndarray] = 1e-3
476
+ initial_precision: Union[float, np.ndarray] = 1.0
477
+ precision_scalar: np.ndarray = field(init=False)
478
+
479
+ coverage_detection: float = 0.1
480
+ coverage_test_source: float = 6.0
481
+
482
+ threshold_function: callable = lambda x: np.quantile(x, 0.95, axis=0)
483
+
484
+ @property
485
+ def nof_sources(self):
486
+ """Get number of sources in the source map."""
487
+ return self.dispersion_model.source_map.nof_sources
488
+
489
+ @property
490
+ def coverage_threshold(self):
491
+ """Compute coverage threshold from detection threshold and test source strength."""
492
+ return self.coverage_test_source / self.coverage_detection
493
+
494
+ def initialise(self, sensor_object: SensorGroup, meteorology: Meteorology, gas_species: GasSpecies):
495
+ """Set up the source model.
496
+
497
+ Extract required information from the sensor, meteorology and gas species objects:
498
+ - Attach coupling calculated using self.dispersion_model.
499
+ - (If self.reversible_jump == True) Attach objects to source model which will be used in RJMCMC sampler,
500
+ they will be required when we need to update the couplings when new source locations are proposed when
501
+ we move/birth/death.
502
+
503
+ Args:
504
+ sensor_object (SensorGroup): object containing sensor data.
505
+ meteorology (MeteorologyGroup): object containing meteorology data.
506
+ gas_species (GasSpecies): object containing gas species information.
507
+
508
+ """
509
+ self.initialise_dispersion_model(sensor_object)
510
+ self.coupling = self.dispersion_model.compute_coupling(
511
+ sensor_object, meteorology, gas_species, output_stacked=True
512
+ )
513
+ self.screen_coverage()
514
+ if self.reversible_jump:
515
+ self.sensor_object = sensor_object
516
+ self.meteorology = meteorology
517
+ self.gas_species = gas_species
518
+
519
+ def initialise_dispersion_model(self, sensor_object: SensorGroup):
520
+ """Initialise the dispersion model.
521
+
522
+ If a dispersion_model has already been attached to this instance, then this function takes no action.
523
+
524
+ If a dispersion_model has not already been attached to the instance, then this function adds a GaussianPlume
525
+ dispersion model, with a default source map that has limits set based on the sensor locations.
526
+
527
+ Args:
528
+ sensor_object (SensorGroup): object containing sensor data.
529
+
530
+ """
531
+ if self.dispersion_model is None:
532
+ source_map = SourceMap()
533
+ sensor_locations = sensor_object.location.to_enu()
534
+ location_object = ENU(
535
+ ref_latitude=sensor_locations.ref_latitude,
536
+ ref_longitude=sensor_locations.ref_longitude,
537
+ ref_altitude=sensor_locations.ref_altitude,
538
+ )
539
+ source_map.generate_sources(
540
+ coordinate_object=location_object,
541
+ sourcemap_limits=np.array(
542
+ [
543
+ [np.min(sensor_locations.east), np.max(sensor_locations.east)],
544
+ [np.min(sensor_locations.north), np.max(sensor_locations.north)],
545
+ [np.min(sensor_locations.up), np.max(sensor_locations.up)],
546
+ ]
547
+ ),
548
+ sourcemap_type="grid",
549
+ )
550
+ self.dispersion_model = GaussianPlume(source_map)
551
+
552
+ def screen_coverage(self):
553
+ """Screen the initial source map for coverage."""
554
+ in_coverage_area = self.dispersion_model.compute_coverage(
555
+ self.coupling, coverage_threshold=self.coverage_threshold, threshold_function=self.threshold_function
556
+ )
557
+ self.coupling = self.coupling[:, in_coverage_area]
558
+ all_locations = self.dispersion_model.source_map.location.to_array()
559
+ screened_locations = all_locations[in_coverage_area, :]
560
+ self.dispersion_model.source_map.location.from_array(screened_locations)
561
+
562
+ def update_coupling_column(self, state: dict, update_column: int) -> dict:
563
+ """Update the coupling, based on changes to the source locations as part of inversion.
564
+
565
+ To be used in two different situations:
566
+ - movement of source locations (e.g. Metropolis Hastings, random walk).
567
+ - adding of new source locations (e.g. reversible jump birth move).
568
+ If [update_column < A.shape[1]]: an existing column of the A matrix is updated.
569
+ If [update_column == A.shape[1]]: a new column is appended to the right-hand side of the A matrix
570
+ (corresponding to a new source).
571
+
572
+ A central assumption of this function is that the sensor information and meteorology information
573
+ have already been interpolated onto the same space/time points.
574
+
575
+ If an update_column is supplied, the coupling for that source location only is calculated to save on
576
+ computation time. If update_column is None, then we just re-compute the whole coupling matrix.
577
+
578
+ Args:
579
+ state (dict): dictionary containing state parameters.
580
+ update_column (int): index of the coupling column to be updated.
581
+
582
+ Returns:
583
+ state (dict): state dictionary containing updated coupling information.
584
+
585
+ """
586
+ self.dispersion_model.source_map.location.from_array(state["z_src"][:, [update_column]].T)
587
+ new_coupling = self.dispersion_model.compute_coupling(
588
+ self.sensor_object, self.meteorology, self.gas_species, output_stacked=True, run_interpolation=False
589
+ )
590
+
591
+ if update_column == state["A"].shape[1]:
592
+ state["A"] = np.concatenate((state["A"], new_coupling), axis=1)
593
+ elif update_column < state["A"].shape[1]:
594
+ state["A"][:, [update_column]] = new_coupling
595
+ else:
596
+ raise ValueError("Invalid column specification for updating.")
597
+ return state
598
+
599
+ def birth_function(self, current_state: dict, prop_state: dict) -> Tuple[dict, float, float]:
600
+ """Update MCMC state based on source birth proposal.
601
+
602
+ Proposed state updated as follows:
603
+ 1- Add column to coupling matrix for new source location.
604
+ 2- If required, adjust other components of the state which correspond to the sources.
605
+ The source emission rate vector will be adjusted using the standardised functionality
606
+ in the openMCMC package.
607
+
608
+ After the coupling has been updated, a coverage test is applied for the new source
609
+ location. If the max coupling is too small, a large contribution is added to the
610
+ log-proposal density for the new state, to force the sampler to reject it.
611
+
612
+ A central assumption of this function is that the sensor information and meteorology information
613
+ have already been interpolated onto the same space/time points.
614
+
615
+ This function assumes that the new source location has been added as the final column of
616
+ the source location matrix, and so will correspondingly append the new coupling column to the right
617
+ hand side of the current state coupling, and append an emission rate as the last element of the
618
+ current state emission rate vector.
619
+
620
+ Args:
621
+ current_state (dict): dictionary containing parameters of the current state.
622
+ prop_state (dict): dictionary containing the parameters of the proposed state.
623
+
624
+ Returns:
625
+ prop_state (dict): proposed state, with coupling matrix and source emission rate vector updated.
626
+ logp_pr_g_cr (float): log-transition density of the proposed state given the current state
627
+ (i.e. log[p(proposed | current)])
628
+ logp_cr_g_pr (float): log-transition density of the current state given the proposed state
629
+ (i.e. log[p(current | proposed)])
630
+
631
+ """
632
+ prop_state = self.update_coupling_column(prop_state, int(prop_state["n_src"]) - 1)
633
+ prop_state["alloc_s"] = np.concatenate((prop_state["alloc_s"], np.array([0], ndmin=2)), axis=0)
634
+ in_cov_area = self.dispersion_model.compute_coverage(
635
+ prop_state["A"][:, -1],
636
+ coverage_threshold=self.coverage_threshold,
637
+ threshold_function=self.threshold_function,
638
+ )
639
+ if not in_cov_area:
640
+ logp_pr_g_cr = 1e10
641
+ else:
642
+ logp_pr_g_cr = 0.0
643
+ logp_cr_g_pr = 0.0
644
+
645
+ return prop_state, logp_pr_g_cr, logp_cr_g_pr
646
+
647
+ @staticmethod
648
+ def death_function(current_state: dict, prop_state: dict, deletion_index: int) -> Tuple[dict, float, float]:
649
+ """Update MCMC state based on source death proposal.
650
+
651
+ Proposed state updated as follows:
652
+ 1- Remove column from coupling for deleted source.
653
+ 2- If required, adjust other components of the state which correspond to the sources.
654
+ The source emission rate vector will be adjusted using the standardised functionality in the general_mcmc repo.
655
+
656
+ A central assumption of this function is that the sensor information and meteorology information have already
657
+ been interpolated onto the same space/time points.
658
+
659
+ Args:
660
+ current_state (dict): dictionary containing parameters of the current state.
661
+ prop_state (dict): dictionary containing the parameters of the proposed state.
662
+ deletion_index (int): index of the source to be deleted in the overall set of sources.
663
+
664
+ Returns:
665
+ prop_state (dict): proposed state, with coupling matrix and source emission rate vector updated.
666
+ logp_pr_g_cr (float): log-transition density of the proposed state given the current state
667
+ (i.e. log[p(proposed | current)])
668
+ logp_cr_g_pr (float): log-transition density of the current state given the proposed state
669
+ (i.e. log[p(current | proposed)])
670
+
671
+ """
672
+ prop_state["A"] = np.delete(prop_state["A"], obj=deletion_index, axis=1)
673
+ prop_state["alloc_s"] = np.delete(prop_state["alloc_s"], obj=deletion_index, axis=0)
674
+ logp_pr_g_cr = 0.0
675
+ logp_cr_g_pr = 0.0
676
+
677
+ return prop_state, logp_pr_g_cr, logp_cr_g_pr
678
+
679
+ def move_function(self, current_state: dict, update_column: int) -> dict:
680
+ """Re-compute the coupling after a source location move.
681
+
682
+ Function first updates the coupling column, and then checks whether the location passes a coverage test. If the
683
+ location does not have good enough coverage, the state reverts to the coupling from the current state.
684
+
685
+ Args:
686
+ current_state (dict): dictionary containing parameters of the current state.
687
+ update_column (int): index of the coupling column to be updated.
688
+
689
+ Returns:
690
+ dict: proposed state, with updated coupling matrix.
691
+
692
+ """
693
+ prop_state = deepcopy(current_state)
694
+ prop_state = self.update_coupling_column(prop_state, update_column)
695
+ in_cov_area = self.dispersion_model.compute_coverage(
696
+ prop_state["A"][:, update_column],
697
+ coverage_threshold=self.coverage_threshold,
698
+ threshold_function=self.threshold_function,
699
+ )
700
+ if not in_cov_area:
701
+ prop_state = deepcopy(current_state)
702
+ return prop_state
703
+
704
+ def make_model(self, model: list) -> list:
705
+ """Take model list and append new elements from current model component.
706
+
707
+ Args:
708
+ model (list): Current list of model elements.
709
+
710
+ Returns:
711
+ list: model list updated with source-related distributions.
712
+
713
+ """
714
+ model = self.make_allocation_model(model)
715
+ model = self.make_source_model(model)
716
+ if self.update_precision:
717
+ model.append(Gamma("lambda_s", shape="a_lam_s", rate="b_lam_s"))
718
+ if self.reversible_jump:
719
+ model.append(
720
+ Uniform(
721
+ response="z_src",
722
+ domain_response_lower=self.site_limits[:, [0]],
723
+ domain_response_upper=self.site_limits[:, [1]],
724
+ )
725
+ )
726
+ model.append(Poisson(response="n_src", rate="rho"))
727
+ return model
728
+
729
+ def make_sampler(self, model: Model, sampler_list: list) -> list:
730
+ """Take sampler list and append new elements from current model component.
731
+
732
+ Args:
733
+ model (Model): Full model list of distributions.
734
+ sampler_list (list): Current list of samplers.
735
+
736
+ Returns:
737
+ list: sampler list updated with source-related samplers.
738
+
739
+ """
740
+ sampler_list = self.make_source_sampler(model, sampler_list)
741
+ sampler_list = self.make_allocation_sampler(model, sampler_list)
742
+ if self.update_precision:
743
+ sampler_list.append(NormalGamma("lambda_s", model))
744
+ if self.reversible_jump:
745
+ sampler_list = self.make_sampler_rjmcmc(model, sampler_list)
746
+ return sampler_list
747
+
748
+ def make_state(self, state: dict) -> dict:
749
+ """Take state dictionary and append initial values from model component.
750
+
751
+ Args:
752
+ state (dict): current state vector.
753
+
754
+ Returns:
755
+ dict: current state vector with source-related parameters added.
756
+
757
+ """
758
+ state = self.make_allocation_state(state)
759
+ state = self.make_source_state(state)
760
+ state["A"] = self.coupling
761
+ state["lambda_s"] = np.array(self.initial_precision, ndmin=1)
762
+ if self.update_precision:
763
+ state["a_lam_s"] = np.ones_like(self.initial_precision) * self.prior_precision_shape
764
+ state["b_lam_s"] = np.ones_like(self.initial_precision) * self.prior_precision_rate
765
+ if self.reversible_jump:
766
+ state["z_src"] = self.dispersion_model.source_map.location.to_array().T
767
+ state["n_src"] = state["z_src"].shape[1]
768
+ state["rho"] = self.rate_num_sources
769
+ return state
770
+
771
+ def make_sampler_rjmcmc(self, model: Model, sampler_list: list) -> list:
772
+ """Create the parts of the sampler related to the reversible jump MCMC scheme.
773
+
774
+ RJ MCMC scheme:
775
+ - create the RandomWalkLoop sampler object which updates the source locations one-at-a-time.
776
+ - create the ReversibleJump sampler which proposes birth/death moves to add/remove sources from the source
777
+ map.
778
+
779
+ Args:
780
+ model (Model): model object containing probability density objects for all uncertain
781
+ parameters.
782
+ sampler_list (list): list of existing samplers.
783
+
784
+ Returns:
785
+ sampler_list (list): list of samplers updated with samplers corresponding to RJMCMC routine.
786
+
787
+ """
788
+ sampler_list[-1].max_variable_size = self.n_sources_max
789
+
790
+ sampler_list.append(
791
+ RandomWalkLoop(
792
+ "z_src",
793
+ model,
794
+ step=self.random_walk_step_size,
795
+ max_variable_size=(3, self.n_sources_max),
796
+ domain_limits=self.site_limits,
797
+ state_update_function=self.move_function,
798
+ )
799
+ )
800
+ matching_params = {"variable": "s", "matrix": "A", "scale": 1.0, "limits": [0.0, 1e6]}
801
+ sampler_list.append(
802
+ ReversibleJump(
803
+ "n_src",
804
+ model,
805
+ step=np.array([1.0], ndmin=2),
806
+ associated_params="z_src",
807
+ n_max=self.n_sources_max,
808
+ state_birth_function=self.birth_function,
809
+ state_death_function=self.death_function,
810
+ matching_params=matching_params,
811
+ )
812
+ )
813
+ return sampler_list
814
+
815
+ def from_mcmc(self, store: dict):
816
+ """Extract results of mcmc from mcmc.store and attach to components.
817
+
818
+ Args:
819
+ store (dict): mcmc result dictionary.
820
+
821
+ """
822
+ self.from_mcmc_group(store)
823
+ self.from_mcmc_dist(store)
824
+ if self.update_precision:
825
+ self.precision_scalar = store["lambda_s"]
826
+
827
+ def plot_iterations(self, plot: "Plot", burn_in_value: int, y_axis_type: str = "linear") -> "Plot":
828
+ """Plot the emission rate estimates source model object against MCMC iteration.
829
+
830
+ Args:
831
+ burn_in_value (int): Burn in value to show in plot.
832
+ y_axis_type (str, optional): String to indicate whether the y-axis should be linear of log scale.
833
+ plot (Plot): Plot object to which this figure will be added in the figure dictionary.
834
+
835
+ Returns:
836
+ plot (Plot): Plot object to which the figures added in the figure dictionary with
837
+ keys 'estimated_values_plot'/'log_estimated_values_plot' and 'number_of_sources_plot'
838
+
839
+ """
840
+ plot.plot_emission_rate_estimates(source_model_object=self, burn_in=burn_in_value, y_axis_type=y_axis_type)
841
+ plot.plot_single_trace(object_to_plot=self)
842
+ return plot
843
+
844
+
845
+ @dataclass
846
+ class Normal(SourceModel, NullGrouping, NormalResponse):
847
+ """Normal model, with null allocation.
848
+
849
+ (Truncated) Gaussian prior for emission rates, no grouping/allocation; no transformation applied to emission rate
850
+ parameters.
851
+
852
+ Can be used in the following cases:
853
+ - Fixed set of sources (grid or specific locations), all with the same Gaussian prior distribution.
854
+ - Variable number of sources, with a common prior distribution, estimated using reversible jump MCMC.
855
+ - Fixed set of sources with a bespoke prior per source (using the allocation to map prior parameters onto
856
+ sources).
857
+
858
+ """
859
+
860
+
861
+ @dataclass
862
+ class NormalSlabAndSpike(SourceModel, SlabAndSpike, NormalResponse):
863
+ """Normal Slab and Spike model.
864
+
865
+ (Truncated) Gaussian prior for emission rates, slab and spike prior, with allocation estimation; no transformation
866
+ applied to emission rate parameters.
867
+
868
+ Attributes:
869
+ initial_precision (np.ndarray): initial precision parameter for a slab and spike case. shape=(2, 1).
870
+ emission_rate_mean (np.ndarray): emission rate prior mean for a slab and spike case. shape=(2, 1).
871
+
872
+ """
873
+
874
+ initial_precision: np.ndarray = field(default_factory=lambda: np.array([1 / (10**2), 1 / (0.01**2)], ndmin=2).T)
875
+ emission_rate_mean: np.ndarray = field(default_factory=lambda: np.array([0, 0], ndmin=2).T)