scatterbrane 0.1.0__tar.gz

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,19 @@
1
+ Copyright (c) <2015> <Katherine Rosenfeld and Michael Johnson>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: scatterbrane
3
+ Version: 0.1.0
4
+ Summary: A python module to simulate the effect of anisotropic scattering on astrophysical images.
5
+ Author-email: Katherine Rosenfeld <krosenf@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, http://krosenfeld.github.io/scatterbrane/
8
+ Keywords: scattering,astronomy,EHT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/x-rst
17
+ License-File: LICENSE
18
+ Requires-Dist: astropy
19
+ Requires-Dist: imageio
20
+ Requires-Dist: matplotlib
21
+ Requires-Dist: numpy
22
+ Requires-Dist: scipy
23
+ Dynamic: license-file
24
+
25
+ scatterbrane
26
+ ============
27
+
28
+ A python module for adding realistic scattering to astronomical images. This version
29
+ represents a very early release so please submit a pull request if you have trouble or
30
+ need help for your application.
31
+
32
+ For installation instructions and help getting started please see `the documentation <http://krosenfeld.github.io/scatterbrane/>`_.
33
+
34
+ Installation
35
+ ------------
36
+
37
+ This project now uses ``pyproject.toml`` and `uv <https://docs.astral.sh/uv/>`_ for dependency management.
38
+
39
+ Install the package and its runtime dependencies with:
40
+
41
+ .. code-block:: bash
42
+
43
+ uv sync
44
+
45
+ Install the release and development tooling with:
46
+
47
+ .. code-block:: bash
48
+
49
+ uv sync --group dev
50
+
51
+ Release workflow
52
+ ----------------
53
+
54
+ Version management is handled with ``bump-my-version``. To create a new tagged release:
55
+
56
+ .. code-block:: bash
57
+
58
+ uv run bump-my-version bump patch
59
+
60
+ This updates the package version, creates a commit, and creates a ``vX.Y.Z`` git tag. Pushing that tag to GitHub triggers the publish workflow for PyPI.
61
+
62
+ Reference
63
+ ---------
64
+
65
+ `Johnson, M. D., & Gwinn, C. R. 2015, ApJ, 805, 180 <http://adsabs.harvard.edu/abs/2015ApJ...805..180J>`_
@@ -0,0 +1,41 @@
1
+ scatterbrane
2
+ ============
3
+
4
+ A python module for adding realistic scattering to astronomical images. This version
5
+ represents a very early release so please submit a pull request if you have trouble or
6
+ need help for your application.
7
+
8
+ For installation instructions and help getting started please see `the documentation <http://krosenfeld.github.io/scatterbrane/>`_.
9
+
10
+ Installation
11
+ ------------
12
+
13
+ This project now uses ``pyproject.toml`` and `uv <https://docs.astral.sh/uv/>`_ for dependency management.
14
+
15
+ Install the package and its runtime dependencies with:
16
+
17
+ .. code-block:: bash
18
+
19
+ uv sync
20
+
21
+ Install the release and development tooling with:
22
+
23
+ .. code-block:: bash
24
+
25
+ uv sync --group dev
26
+
27
+ Release workflow
28
+ ----------------
29
+
30
+ Version management is handled with ``bump-my-version``. To create a new tagged release:
31
+
32
+ .. code-block:: bash
33
+
34
+ uv run bump-my-version bump patch
35
+
36
+ This updates the package version, creates a commit, and creates a ``vX.Y.Z`` git tag. Pushing that tag to GitHub triggers the publish workflow for PyPI.
37
+
38
+ Reference
39
+ ---------
40
+
41
+ `Johnson, M. D., & Gwinn, C. R. 2015, ApJ, 805, 180 <http://adsabs.harvard.edu/abs/2015ApJ...805..180J>`_
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scatterbrane"
7
+ dynamic = ["version"]
8
+ description = "A python module to simulate the effect of anisotropic scattering on astrophysical images."
9
+ readme = "README.rst"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Katherine Rosenfeld", email = "krosenf@gmail.com" },
15
+ ]
16
+ keywords = ["scattering", "astronomy", "EHT"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Topic :: Scientific/Engineering :: Astronomy",
24
+ ]
25
+ dependencies = [
26
+ "astropy",
27
+ "imageio",
28
+ "matplotlib",
29
+ "numpy",
30
+ "scipy",
31
+ ]
32
+
33
+ [project.urls]
34
+ Documentation = "http://krosenfeld.github.io/scatterbrane/"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "bump-my-version",
39
+ "build",
40
+ ]
41
+ docs = [
42
+ "sphinx",
43
+ "sphinx-rtd-theme",
44
+ ]
45
+
46
+ [tool.setuptools]
47
+ packages = ["scatterbrane"]
48
+
49
+ [tool.setuptools.dynamic]
50
+ version = { attr = "scatterbrane.__version__" }
51
+
52
+ [tool.bumpversion]
53
+ current_version = "0.1.0"
54
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
55
+ serialize = ["{major}.{minor}.{patch}"]
56
+ search = "{current_version}"
57
+ replace = "{new_version}"
58
+ regex = false
59
+ allow_dirty = false
60
+ commit = true
61
+ tag = true
62
+ sign_tags = false
63
+ tag_name = "v{new_version}"
64
+ tag_message = "Release {new_version}"
65
+ message = "Bump version: {current_version} -> {new_version}"
66
+
67
+ [[tool.bumpversion.files]]
68
+ filename = "pyproject.toml"
69
+
70
+ [[tool.bumpversion.files]]
71
+ filename = "scatterbrane/__init__.py"
@@ -0,0 +1,4 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .brane import Brane
4
+ from .tracks import Target
@@ -0,0 +1,481 @@
1
+ """
2
+ .. module:: brane
3
+ :platform: Unix
4
+ :synopsis: Simulate effect of anisotropic scattering.
5
+
6
+ .. moduleauthor:: Katherine Rosenfeld <krosenf@gmail.com>
7
+ .. moduleauthor:: Michael Johnson
8
+
9
+ Default settings are appropriate for Sgr A*
10
+
11
+ Resources:
12
+ Bower et al. (2004, 2006)
13
+
14
+ """
15
+
16
+ from __future__ import print_function
17
+
18
+ import numpy as np
19
+ import matplotlib.pyplot as plt
20
+ from scipy.interpolate import RectBivariateSpline
21
+ from scipy.ndimage.filters import gaussian_filter
22
+ from scipy.ndimage.interpolation import zoom,rotate
23
+ from numpy import (pi,sqrt,log,sin,cos,exp,ceil,arange,
24
+ min,abs,ones,radians,dot,transpose,
25
+ zeros_like,clip,empty,empty,empty_like,reshape)
26
+ from numpy.fft import fft2,fftfreq
27
+ from astropy.constants import au,pc
28
+ from astropy import units
29
+ import logging
30
+ from scatterbrane import utilities
31
+
32
+ __all__ = ["Brane"]
33
+
34
+ class Brane(object):
35
+ """
36
+ Scattering simulation object.
37
+
38
+ :param model: ``(n, n)``
39
+ Numpy array of the source image.
40
+ :param dx: scalar
41
+ Resolution element of model in uas.
42
+ :param nphi: (optional) ``(2, )`` or scalar
43
+ Number of pixels in a screen. This may be a tuple specifying the dimensions of a rectangular screen.
44
+ :param screen_res: (optional) scalar
45
+ Size of a screen pixel in units of :math:`R_0`.
46
+ :param wavelength: (optional) scalar
47
+ Observing wavelength in meters.
48
+ :param dpc: (optional) scalar
49
+ Observer-Source distance in parsecs.
50
+ :param rpc: (optional) scalar
51
+ Observer-Scatterer distance in parsecs.
52
+ :param r0: (optional) scalar
53
+ Phase coherence length along major axis as preset string or in km.
54
+ :param r_outer: (optional) scalar
55
+ Outer scale of turbulence in units of :math:`R_0`.
56
+ :param r_inner: (optional) scalar
57
+ Inner scale of turbulence in units of :math:`R_0`.
58
+ :param alpha: (optional) string or scalar
59
+ Preset string or float to set power-law index for tubulence scaling (e.g., Kolmogorov has :math:`\\alpha= 5/3`)
60
+ :param anisotropy: (optional) scalar
61
+ Anisotropy for screen is the EW / NS elongation.
62
+ :param pa: (optional) scalar
63
+ Orientation of kernel's major axis, East of North, in degrees.
64
+ :param live_dangerously: (optional) bool
65
+ Skip the parameter checks?
66
+ :param think_positive: (optional) bool
67
+ Should we enforce that the source image has no negative pixel values?
68
+
69
+ :returns: An instance of a scattering simulation.
70
+
71
+ :Example:
72
+
73
+ .. code-block:: python
74
+
75
+ s = Brane(m,dx,nphi=2**12,screen_res=5.,wavelength=3.0e-3,dpc=8.4e3,rpc=5.8e3)
76
+
77
+ where ``s`` is the class instance, ``m`` is the image array, ``nphi`` is the number of screen pixels,
78
+ ``wavelength`` is the observing wavelength.
79
+
80
+ .. note:: :math:`R_0` is the phase coherence length and Sgr A* defaults are from Bower et al. (2006).
81
+ """
82
+
83
+ def __init__(self,model,dx,nphi=2**12,screen_res=2,\
84
+ wavelength=1.3e-3,dpc=8400,rpc=5800,r0 = 'sgra',\
85
+ r_outer=10000000,r_inner=12,alpha='kolmogorov',\
86
+ anisotropy=2.045,pa=78,match_screen_res=False,live_dangerously=False,think_positive=False):
87
+
88
+ # set initial members
89
+ self.logger = logging.getLogger(self.__class__.__name__)
90
+ self.live_dangerously = live_dangerously
91
+ self.think_positive = think_positive
92
+
93
+ self.wavelength = wavelength*1e-3 # observing wavelength in km
94
+ self.dpc = float(dpc) # earth-source distance in pc
95
+ self.rpc = float(rpc) # R: source-scatterer distance in pc
96
+ self.d = self.dpc - self.rpc # D: earth-scatterer distance in pc
97
+ self.m = self.d/self.rpc # magnification factor (D/R)
98
+ if r0 == 'sgra':
99
+ # major axis (EW) phase coherence length in km
100
+ self.r0 = 3136.67*(1.3e-6/self.wavelength)
101
+ else:
102
+ try:
103
+ self.r0 = float(r0)
104
+ except:
105
+ raise ValueError('Bad value for r0')
106
+ self.anisotropy = anisotropy # anisotropy for screen = (EW / NS elongation)
107
+ self.pa = pa # orientation of major axis, E of N (or CCW of +y)
108
+
109
+ # Fresnel scale in km
110
+ self.rf = sqrt(self.dpc*pc.to(units.km).value / (2*pi / self.wavelength) * self.m / (1+self.m)**2)
111
+
112
+ # compute pixel scale for image
113
+ if match_screen_res:
114
+ self.ips = 1
115
+ self.screen_dx = screen_res * self.r0
116
+ else:
117
+ self.screen_dx = screen_res * self.r0 # size of a screen pixel in km
118
+ self.ips = int(ceil(1e-6*dx*self.d*au.to(units.km).value/self.screen_dx)) # image pixel / screen pixel
119
+
120
+ # image arrays
121
+ self.dx = 1e6 * self.ips * (self.screen_dx / (au.to(units.km).value * self.d)) # image pixel scale in uas
122
+ self.nx = int(ceil(model.shape[-1] * dx / self.dx)) # number of image pixels
123
+ self.model = model # source model
124
+ self.model_dx = dx # source model resolution
125
+ self.iss = np.array([],dtype=np.float64) # scattered image
126
+ self.isrc = np.array([],dtype=np.float64) # source image at same scale as scattered image
127
+
128
+ # screen parameters
129
+ if type(nphi) == int:
130
+ self.nphi = (nphi,nphi) # size of screen array
131
+ else:
132
+ self.nphi = nphi
133
+ self.nphi = np.asarray(self.nphi)
134
+ self.r_inner = r_inner # inner turbulence scale in r0
135
+ self.r_outer = r_outer # outer turbulence scale in r0
136
+ #self.qmax = 1.*screen_res/r_inner # 1 / inner turbulence scale in pix
137
+ #self.qmin = 1.*screen_res/r_outer # 1 / outer tubulence scale in pix
138
+ if alpha == 'kolmogorov':
139
+ self.alpha = 5./3
140
+ else:
141
+ try:
142
+ self.alpha = float(alpha)
143
+ except:
144
+ raise ValueError('Bad value for alpha')
145
+
146
+ # use logger to report
147
+ self.chatter()
148
+
149
+ # includes sanity check
150
+ self.setModel(self.model,self.model_dx,think_positive=self.think_positive)
151
+
152
+ def _checkSanity(self):
153
+ '''
154
+ Check that screen parameters are consistent.
155
+ '''
156
+
157
+ # sanity check: is screen large enough?
158
+ assert np.ceil(self.nx*self.ips)+2 < np.min(self.nphi), \
159
+ "screen is not large enough: {0} > {1}".\
160
+ format(int(np.ceil(self.ips*self.nx)+2),np.min(self.nphi))
161
+
162
+ # sanity check: is image square?
163
+ assert self.model.shape[-1] == self.model.shape[-2], \
164
+ 'source image must be square'
165
+
166
+ # sanity check: integer number of screen pixels / image pixel?
167
+ assert self.ips % 1 == 0, 'image/screen pixels should be an integer'
168
+
169
+ # is inner turbulence scale larger than r0?
170
+ #assert 1./self.qmax > self.r0/self.screen_dx, 'turbulence inner scale < r0'
171
+ assert self.r_inner > 1., 'turbulence inner scale < r0'
172
+
173
+ # check image smoothness
174
+ V = fft2(self.isrc)
175
+ freq = fftfreq(self.nx,d=self.dx*radians(1.)/(3600*1e6))
176
+ u = dot(transpose([np.ones(self.nx)]),[freq])
177
+ v = dot(transpose([freq]),[ones(self.nx)])
178
+ try:
179
+ if max(abs(V[sqrt(u*u+v*v) > (1.+self.m)*self.r_inner*self.r0/self.wavelength])) / self.isrc.sum() > 0.01:
180
+ self.logger.warning('image is not smooth enough: {0:g} > 0.01'.format(max(abs(V[sqrt(u*u+v*v) > (1.+self.m)*self.r_inner*self.r0/self.wavelength])) / self.isrc.sum()))
181
+ except ValueError:
182
+ self.logger.warning('r_inner is too large to test smoothness')
183
+
184
+ # is screen pixel smaller than inner turbulence scale?
185
+ #assert 1./self.qmax >= 1, 'screen pixel > turbulence inner scale'
186
+ assert self.r_inner*self.r0/self.screen_dx >= 1, 'screen pixel > turbulence inner scale'
187
+
188
+ if (self.rf*self.rf/self.r0/(self.ips*self.screen_dx) < 3):
189
+ self.logger.warning('WARNING: image resolution is approaching Refractive scale')
190
+
191
+ def chatter(self):
192
+ '''
193
+ Print information about the current scattering simulation where many parameters are cast as integers.
194
+ '''
195
+
196
+ fmt = "{0:32s} :: "
197
+ self.logger.info( (fmt + "{1:g}").format('Observing wavelength [mm]',1e6*self.wavelength) )
198
+ self.logger.info( (fmt + "{1:d}").format('Phase coherence length [km]',int(self.r0)) )
199
+ self.logger.info( (fmt + "{1:d}").format('Fresnel scale [km]',int(self.rf)) )
200
+ self.logger.info( (fmt + "{1:d}").format('Refractive scale [km]',int(self.rf**2/self.r0)) )
201
+ #self.logger.info( (fmt + "{1:d}").format('Inner turbulence scale [km]',int(self.screen_dx/self.qmax)))
202
+ self.logger.info( (fmt + "{1:d}").format('Inner turbulence scale [km]',int(self.r_inner*self.r0)))
203
+ self.logger.info( (fmt + "{1:d}").format('Screen resolution [km]',int(self.screen_dx)))
204
+ self.logger.info( (fmt + "{1:d} {2:d}").format('Linear filling factor [%,%]',*map(int,100.*self.nx*self.ips/self.nphi)) )
205
+ self.logger.info( (fmt + "{1:g}").format('Image resolution [uas]',self.dx))
206
+ self.logger.info( (fmt + "{1:d}").format('Image size',int(self.nx)))
207
+
208
+ def _generateEmptyScreen(self):
209
+ '''
210
+ Create an empty screen.
211
+ '''
212
+ if not self.live_dangerously: self._checkSanity()
213
+ self.phi = np.zeros(self.nphi)
214
+
215
+ def setModel(self,model,dx,think_positive=False):
216
+ '''
217
+ Set new model for the source.
218
+
219
+ :param model: ``(n, n)``
220
+ Numpy image array.
221
+ :param dx: scalar
222
+ Pixel size in microarcseconds.
223
+ :param think_positive: (optional) bool
224
+ Should we enforce that the source image has no negative pixel values?
225
+ '''
226
+ self.nx = int(ceil(model.shape[-1] * dx / self.dx)) # number of image pixels
227
+ self.model = model # source model
228
+ self.model_dx = dx # source model resolution
229
+
230
+ # load source image that has size and resolution compatible with the screen.
231
+ self.isrc = np.empty(2*(self.nx,))
232
+ self.think_positive = think_positive
233
+
234
+ M = self.model.shape[1] # size of original image array
235
+ f_img = RectBivariateSpline(self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
236
+ self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
237
+ self.model)
238
+
239
+ xx_,yy_ = np.meshgrid((np.arange(self.nx) - 0.5*(self.nx-1)),\
240
+ (np.arange(self.nx) - 0.5*(self.nx-1)),indexing='xy')
241
+
242
+
243
+ m = f_img.ev(yy_.flatten(),xx_.flatten()).reshape(2*(self.nx,))
244
+ self.isrc = m * (self.dx/self.model_dx)**2 # rescale for change in pixel size
245
+
246
+ if self.think_positive:
247
+ self.isrc[self.isrc < 0] = 0
248
+
249
+ if not self.live_dangerously: self._checkSanity()
250
+
251
+ def generatePhases(self,seed=None,save_phi=None):
252
+ '''
253
+ Generate screen phases.
254
+
255
+ :param seed: (optional) scalar
256
+ Seed for random number generator
257
+ :param save_phi: (optional) string
258
+ To save the screen, set this to the filename.
259
+ '''
260
+
261
+ # check setup
262
+ if not self.live_dangerously: self._checkSanity()
263
+
264
+ # seed the generator
265
+ if seed != None:
266
+ np.random.seed(seed=seed)
267
+
268
+ # include anisotropy
269
+ qx2 = dot(transpose([np.ones(self.nphi[0])]),[np.fft.rfftfreq(self.nphi[1])**2])
270
+ qy2 = dot(transpose([np.fft.fftfreq(self.nphi[0])**2*self.anisotropy**2]),[np.ones(self.nphi[1]//2+1)])
271
+ rr = qx2+qy2
272
+ rr[0,0] = 0.02 # arbitrary normalization
273
+
274
+ # generating phases with given power spectrum
275
+ size = rr.shape
276
+ qmax2 = (self.r_inner*self.r0/self.screen_dx)**-2
277
+ qmin2 = (self.r_outer*self.r0/self.screen_dx)**-2
278
+ phi_t = (1/sqrt(2) * sqrt(exp(-1./qmax2*rr) * (rr + qmin2)**(-0.5*(self.alpha+2.)))) \
279
+ * (np.random.normal(size=size) + 1j * np.random.normal(size=size))
280
+
281
+ # calculate phi
282
+ self.phi = np.fft.irfft2(phi_t)
283
+
284
+ # normalize structure function
285
+ nrm = self.screen_dx/(self.r0*sqrt(self._getPhi(1,0)))
286
+ self.phi *= nrm
287
+
288
+ # save screen
289
+ if save_phi != None:
290
+ np.save(save_phi,self.phi)
291
+
292
+ def _checkDPhi(self,nLag=5):
293
+ '''
294
+ Report the phase structure function for various lags.
295
+
296
+ :param nLag: (optional) int
297
+ Number of lags to report starting with 0.
298
+ '''
299
+ self.logger.info( "\nEstimates of the phase structure function at various lags:")
300
+ for i in np.arange(nLag):
301
+ self.logger.info( "lag ",i, self._getPhi(i,0), self._getPhi(0,i), self._getPhi(i,i))
302
+
303
+ def _getPhi(self,lag_x,lag_y):
304
+ '''
305
+ Empirical estimate for phase structure function
306
+
307
+ :param lag_x: int
308
+ Screen pixels to lag in x direction.
309
+ :param lag_y: int
310
+ Screen pixels to lag in y direction.
311
+ '''
312
+ assert (lag_x < self.nphi[0]) and (lag_x < self.nphi[1]), "lag choice larger than screen array"
313
+ # Y,X
314
+ if (lag_x == 0 and lag_y == 0):
315
+ return 0.
316
+ if (lag_x == 0):
317
+ return 1.*((self.phi[:-1*lag_y,:] - self.phi[lag_y:,:])**2).sum()/(self.nphi[0]*(self.nphi[1]-lag_y))
318
+ if (lag_y == 0):
319
+ return 1.*((self.phi[:,:-1*lag_x] - self.phi[:,lag_x:])**2).sum()/((self.nphi[0]-lag_x)*self.nphi[1])
320
+ else:
321
+ return (1.*((self.phi[:-1*lag_y,:-1*lag_x] - self.phi[lag_y:,lag_x:])**2).sum()/((self.nphi[1]-lag_y)*(self.nphi[0]-lag_x)))
322
+
323
+ def readScreen(self,filename):
324
+ '''
325
+ Read in screen phases from a file.
326
+
327
+ :param filename: string
328
+ File containing the screen phases.
329
+ '''
330
+ self.phi = np.fromfile(filename,dtype=np.float64).reshape(self.nphi)
331
+
332
+ def _calculate_dphi(self,move_pix=0):
333
+ '''
334
+ Calculate the screen gradient.
335
+
336
+ :param move_pix: (optional) int
337
+ Number of pixels to roll the screen (for time evolution).
338
+
339
+ :returns: ``(nx, nx)``, ``(nx, nx)``
340
+ numpy arrays containing the dx,dy components of the gradient vector.
341
+
342
+ .. note:: Includes factors of the Fresnel scale and the result is in units of the source image.
343
+ '''
344
+ ips = self.ips # number of screen pixels per image pixel
345
+ # -- when this != 1, some sinusoidal signal
346
+ # over time with period of image_resolution
347
+ nx = self.nx # number of image pixels
348
+ rf = self.rf / self.screen_dx # Fresnel scale in screen pixels
349
+ assert move_pix < (self.nphi[1] - self.nx*self.ips), 'screen is not large enough'
350
+ dphi_x = (0.5 * rf * rf / ips ) * \
351
+ (self.phi[0:ips*nx:ips,2+move_pix:ips*nx+2+move_pix:ips] -
352
+ self.phi[0:ips*nx:ips,0+move_pix:ips*nx+move_pix :ips])
353
+ dphi_y = (0.5 * rf * rf / ips ) * \
354
+ (self.phi[2:ips*nx+2:ips,0+move_pix:ips*nx+move_pix:ips] -
355
+ self.phi[0:ips*nx :ips,0+move_pix:ips*nx+move_pix:ips])
356
+
357
+ self.logger.debug('{0:d},{1:d}'.format(*dphi_x.shape))
358
+ return dphi_x,dphi_y
359
+
360
+ def scatter(self,move_pix=0,scale=1):
361
+ '''
362
+ Generate the scattered image which is stored in the ``iss`` member.
363
+
364
+ :param move_pix: (optional) int
365
+ Number of pixels to roll the screen (for time evolution).
366
+ :param scale: (optional) scalar
367
+ Scale factor for gradient. To simulate the scattering effect at another
368
+ wavelength this is (lambda_new/lambda_old)**2
369
+ '''
370
+
371
+ M = self.model.shape[-1] # size of original image array
372
+ N = self.nx # size of output image array
373
+
374
+ #if not self.live_dangerously: self._checkSanity()
375
+
376
+ # calculate phase gradient
377
+ dphi_x,dphi_y = self._calculate_dphi(move_pix=move_pix)
378
+
379
+ if scale != 1:
380
+ dphi_x *= scale
381
+ dphi_y *= scale
382
+
383
+ xx_,yy = np.meshgrid((np.arange(N) - 0.5*(N-1)),\
384
+ (np.arange(N) - 0.5*(N-1)),indexing='xy')
385
+
386
+ # check whether we care about PA of scattering kernel
387
+ if self.pa != None:
388
+ f_model = RectBivariateSpline(self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
389
+ self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
390
+ self.model)
391
+
392
+ # apply rotation
393
+ theta = -(90 * pi / 180) + np.radians(self.pa) # rotate CW 90 deg, then CCW by PA
394
+ xx_ += dphi_x
395
+ yy += dphi_y
396
+ xx = cos(theta)*xx_ - sin(theta)*yy
397
+ yy = sin(theta)*xx_ + cos(theta)*yy
398
+ self.iss = f_model.ev(yy.flatten(),xx.flatten()).reshape((self.nx,self.nx))
399
+
400
+ # rotate back and clip for positive values for I
401
+ if self.think_positive:
402
+ self.iss = clip(rotate(self.iss,-1*theta/np.pi*180,reshape=False),a_min=0,a_max=1e30) * (self.dx/self.model_dx)**2
403
+ else:
404
+ self.iss = rotate(self.iss,-1*theta/np.pi*180,reshape=False) * (self.dx/self.model_dx)**2
405
+
406
+ # otherwise do a faster lookup rather than the expensive interpolation.
407
+ else:
408
+ yyi = np.rint((yy+dphi_y+self.nx/2)).astype(np.int) % self.nx
409
+ xxi = np.rint((xx_+dphi_x+self.nx/2)).astype(np.int) % self.nx
410
+ if self.think_positive:
411
+ self.iss = clip(self.isrc[yyi,xxi],a_min=0,a_max=1e30)
412
+ else:
413
+ self.iss = self.isrc[yyi,xxi]
414
+
415
+
416
+ def _load_src(self,stokes=(0,),think_positive=True):
417
+ '''
418
+ Load the source image from model (changes size and resolution to match the screen).
419
+
420
+ :param stokes: (optional) tuple
421
+ Stokes parameters to consider.
422
+ :param think_positive: (optional) bool
423
+ Should we enforce that the source image has no negative pixel values?
424
+ '''
425
+ M = self.model.shape[1] # size of original image array
426
+ N = self.nx # size of output image array
427
+
428
+ if len(self.model.shape) > 2:
429
+ self.isrc = np.empty((self.model.shape[-1],N,N))
430
+ else:
431
+ self.isrc = np.empty((1,N,N))
432
+ self.model = np.reshape(self.model,(1,M,M))
433
+
434
+ for s in stokes:
435
+ f_img = RectBivariateSpline(self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
436
+ self.model_dx/self.dx*(np.arange(M) - 0.5*(M-1)),\
437
+ self.model[s,:,:])
438
+
439
+ xx_,yy_ = np.meshgrid((np.arange(N) - 0.5*(N-1)),\
440
+ (np.arange(N) - 0.5*(N-1)),indexing='xy')
441
+
442
+
443
+ m = f_img.ev(yy_.flatten(),xx_.flatten()).reshape((self.nx,self.nx))
444
+ res = m * (self.dx/self.model_dx)**2 # rescale for change in pixel size
445
+
446
+ if s == 0 and think_positive:
447
+ res[res < 0] = 0
448
+
449
+ self.isrc[s,:,:] = res
450
+
451
+ self.model = np.squeeze(self.model)
452
+ self.isrc = np.squeeze(self.isrc)
453
+
454
+ def saveSettings(self,filename):
455
+ '''
456
+ Save screen settings to a file.
457
+
458
+ :param filename: string
459
+ settings filename
460
+ '''
461
+ f = open(filename,"w")
462
+ f.write("wavelength \t {0}\n".format(self.wavelength))
463
+ f.write("dpc \t {0}\n".format(self.dpc))
464
+ f.write("rpc \t {0}\n".format(self.rpc))
465
+ f.write("d \t {0}\n".format(self.d))
466
+ f.write("m \t {0}\n".format(self.m))
467
+ f.write("r0 \t {0}\n".format(self.r0))
468
+ f.write("anisotropy \t {0}\n".format(self.anisotropy))
469
+ f.write("pa \t {0}\n".format(self.pa))
470
+ f.write("nphix \t {0}\n".format(self.nphi[0])) # size of phase screen
471
+ f.write("nphiy \t {0}\n".format(self.nphi[1])) # size of phase screen
472
+ f.write("screen_dx \t {0}\n".format(self.screen_dx))
473
+ f.write("rf \t {0}\n".format(self.rf))
474
+ f.write("ips \t {0}\n".format(self.ips))
475
+ f.write("dx \t {0}\n".format(self.dx))
476
+ f.write("nx \t {0}\n".format(self.nx))
477
+ f.write("qmax \t {0}\n".format(self.r_inner)) # inner turbulence scale in r0
478
+ f.write("qmin \t {0}\n".format(self.r_outer)) # outer turbulence scale in r0
479
+ #f.write("qmax \t {0}\n".format(self.qmax)) # 1./inner turbulence scale in screen pixels
480
+ #f.write("qmin \t {0}\n".format(self.qmin)) # 1./inner turbulence scale in screen pixels
481
+ f.close()
@@ -0,0 +1,397 @@
1
+ """
2
+ .. module:: tracks
3
+ :platform: Unix
4
+ :synopsis: Lightweight module for generating visibilities and closure phases
5
+
6
+ .. moduleauthor:: Katheirne Rosenfeld <krosenf@gmail.com>
7
+ .. moduleauthor:: Michael Johnson
8
+
9
+ """
10
+
11
+ from __future__ import print_function
12
+
13
+ import itertools
14
+ from numpy import (pi,array,cos,sin,cross,dot,asarray,linspace,sum,transpose,dot,exp,angle,
15
+ newaxis,prod,flipud,arange,zeros,complex64,abs,radians)
16
+ from numpy.fft import fftshift,fftfreq
17
+ from numpy.linalg import norm
18
+ import logging
19
+
20
+ __all__ = ["Target"]
21
+
22
+ class Target(object):
23
+ '''
24
+ Class for constructing visibility samples.
25
+
26
+ :param wavelength: (optional)
27
+ Scalar wavelength in meters.
28
+ :param position: (optional) ``(2, )``
29
+ A tuple with right ascension and declination of the source in (hours, degrees) or the preset string "sgra".
30
+ :param obspos: (optional)
31
+ A dictionary of observatories and their geocentric positions.
32
+ :param elevation_cuts: (optional) ``(2, )``
33
+ The upper and lower bound on the observing zenith angle.
34
+
35
+ .. note:: The zenith angle cut corresponds to whether the site can observe the source. As a result, for functions such as :func:`generateTracks`, the number of uv samples returned will be less than or equal to the number of samples set by the ``times`` argument. To disable the zenith angle cut you can initialize :class:`Target` with ``zenith_cuts`` = ``[180.,0.]``.
36
+ '''
37
+
38
+ def __init__(self,wavelength=1.3e-3,position='sgra',obspos='eht',zenith_cuts=[75.,0.]):
39
+
40
+ if position=='sgra':
41
+ ra,dec = (17+45./60+40.0409/3600,-1*(29+28.118/3600))
42
+ else:
43
+ ra,dec = position
44
+
45
+ # observing settings
46
+ self.dec = dec
47
+ self.ra = ra
48
+ self.wavelength = wavelength
49
+ self.cos_zenith_cuts = cos(radians(zenith_cuts))
50
+
51
+ # derived quantities
52
+ self.target_vec = array([cos(self.dec * pi / 180), 0., sin(self.dec * pi / 180)])
53
+ self.projU = cross([0.,0.,1.],self.target_vec)
54
+ self.projU /= norm(self.projU)
55
+ self.projV = -1.*cross(self.projU,self.target_vec)
56
+
57
+ # dictionary of observatories and their positions
58
+ l = self.wavelength # in meters
59
+ if obspos == 'eht':
60
+ self.obs = dict([("SMA",array([-5464523.400, -2493147.080, 2150611.750])/l),\
61
+ ("SMT",array([-1828796.200, -5054406.800, 3427865.200])/l),\
62
+ ("CARMA",array([-2397431.300, -4482018.900, 3843524.500])/l),\
63
+ ("LMT",array([-768713.9637, -5988541.7982, 2063275.9472])/l),\
64
+ ("ALMA",array([2225037.1851, -5441199.1620, -2479303.4629])/l),\
65
+ ("PV",array([5088967.9000, -301681.6000, 3825015.8000])/l),\
66
+ ("PDBI",array([4523998.40, 468045.240, 4460309.760])/l),\
67
+ ("SPT",array([0.0, 0.0, -6359587.3])/l),\
68
+ ("HAYSTACK",array([1492460, -4457299, 4296835])/l),\
69
+ ]) # in wavelengths
70
+ else:
71
+ self.obs = obspos
72
+ for k in self.obs.iterkeys():
73
+ self.obs[k] /= l
74
+
75
+ def sites(self,s):
76
+ '''
77
+ Look up geocentric positions of sites by their string keyword.
78
+
79
+ :param s: List of site keys.
80
+
81
+ :returns: Array of site geocentric positions.
82
+ '''
83
+ return array([self.obs[s_] for s_ in s])
84
+
85
+ def _RR(self,v,theta):
86
+ '''
87
+ Rotates the vector v by angle theta.
88
+
89
+ :param v: A vector (x,y,z).
90
+ :param theta: Angle for CCW rotation in radians.
91
+
92
+ :returns: Rotated vector (x',y',z')
93
+ '''
94
+ return array([cos(theta)*v[0] - sin(theta)*v[1],\
95
+ sin(theta)*v[0] + cos(theta)*v[1],\
96
+ v[2]])
97
+
98
+ def _cosZenith(self,v,theta):
99
+ '''
100
+ :param v: ``(3, )`` vector.
101
+ :param theta: Scalar angle in radians.
102
+
103
+ :returns: cos(zenith angle) = sin(elevation)
104
+ '''
105
+ return dot(self._RR(v,theta),self.target_vec) / (norm(v) * norm(self.target_vec))
106
+
107
+
108
+ def _RRelevcut(self,v,theta):
109
+ '''
110
+ Does vector v pass the zenith angle cut?
111
+
112
+ :param v: ``(3, )`` vector
113
+ :param theta: Scalar angle in radians.
114
+ '''
115
+ if abs(self._cosZenith(v,theta)-0.5*sum(self.cos_zenith_cuts)) < 0.5*sum([-1,1]*self.cos_zenith_cuts):
116
+ return True
117
+ else:
118
+ return False
119
+
120
+ def generateUV(self,site_pair,theta):
121
+ '''
122
+ Generate uv baseline.
123
+
124
+ :param site_pair: ``(2, 3)``
125
+ Geocentric (x,y,z) positions of two sites.
126
+ :param theta: scalar
127
+ Angle in radians.
128
+
129
+ :returns: ``(2, )``
130
+ A uv point.
131
+ '''
132
+ return array([dot(self._RR(site_pair[0],theta)-self._RR(site_pair[1],theta),self.projU),\
133
+ dot(self._RR(site_pair[0],theta)-self._RR(site_pair[1],theta),self.projV)])
134
+
135
+ def hour(self,theta):
136
+ '''
137
+ Convert to UT time.
138
+
139
+ :param theta: ``(n, )`` vector of angles in radians.
140
+
141
+ :returns: ``(n, )`` vector of times in hours.
142
+ '''
143
+ return (theta / (2. * pi) * 24. + self.ra) % 24
144
+
145
+ def theta(self,hour):
146
+ '''
147
+ Convert from UT to angle.
148
+
149
+ :param hour: ``(n, )``
150
+ Vector of times in hours.
151
+
152
+ :returns: ``(n, )``
153
+ Vector of angles in radians.
154
+ '''
155
+ return ((hour - self.ra) / 24. * 2. * pi) % (2 * pi)
156
+
157
+ def _gst(self,hour):
158
+ return ((hour-12.) % 24.) - 12.
159
+
160
+ def generateTracks(self,sites,times=(0.,24.,int((24.-0.)*60./10.)),return_hour=False):
161
+ '''
162
+ Generates uv-samples where the sample rate is to be equally spaced in time.
163
+ For instance, if you want samples spaced by 5 minutes ranging from 1 UT to 4 UT
164
+ (non-inclusive) you would set ``times`` = ``(1, 4, int((4.-1.)*60./5.))``. As
165
+ another example, you would generate 128 samples separated by 10 seconds if
166
+ ``times`` = ``(0., 128*10./3600, 128)``.
167
+
168
+ :param sites: ``(num_sites, 3)``
169
+ Numpy array of sites' geocentric positions.
170
+ :param times: (optional) ``(start, stop, num_samples)``
171
+ Tuple setting the spacing of the uv-samples in time. The ``start`` and ``stop`` times should be in hours and in the range [0,24]. ``stop`` is non-inclusive.
172
+ :param return_hour: (optional) bool
173
+ Return hour of uv samples as second item to unpack.
174
+
175
+ :returns: ``(num_measurements, 2)``
176
+ Numpy array with uv samples where ``num_measurements`` :math:`\\leq` ``num_samples``.
177
+
178
+ '''
179
+
180
+ times = linspace(times[0],times[1],num=times[2],endpoint=False)
181
+ uv = []
182
+ hr = []
183
+ for h in times:
184
+ theta = self.theta(h)
185
+ for baseline in itertools.combinations(sites,2):
186
+ if self._RRelevcut(baseline[0],theta) and self._RRelevcut(baseline[1],theta):
187
+ if return_hour:
188
+ hr.append(h)
189
+ hr.append(h)
190
+ uv.append(self.generateUV((baseline[0],baseline[1]),theta))
191
+ uv.append(self.generateUV((baseline[1],baseline[0]),theta))
192
+
193
+ if return_hour:
194
+ return (array(uv),array(hr))
195
+ else:
196
+ return asarray(uv)
197
+
198
+ def calculateCA(self,img,dx,uvQuadrangle):
199
+ '''
200
+ Calculate closure amplitudes for numpy arrays of 4 baselines:
201
+
202
+ .. math::
203
+
204
+ A_{abcd} = \\frac{|V_{ab}||V_{cd}|}{|V_{ac}||V_{bd}|}
205
+
206
+ :param img: ``(n, n)``
207
+ Numpy image array.
208
+ :param dx: scalar
209
+ Pixel size of image in microarcseconds.
210
+ :param uvQuadrangle: ``(num_measurements, 4, 2)``
211
+ The uv samples for which to calculate the closure amplitude. The baseline order along axis=1 should be (ab,cd,ac,bd).
212
+
213
+ :returns: ``(num_measurements, )``
214
+ Closure amplitudes.
215
+ '''
216
+
217
+ # case we are only given one time sample
218
+ if len(uvQuadrangle.shape) == 2: uvQuadrangle = uvQuadrangle[newxais,:]
219
+
220
+ ca = []
221
+ for quad in uvQuadrangle:
222
+ v = array([ abs(self.FTElementFast(img,dx,t)) for t in quad ])
223
+ ca.append( v[0]*v[1]/(v[2]*v[3]) )
224
+ return asarray(ca)
225
+
226
+ def calculateCP(self,img,dx,uvTriangle):
227
+ '''
228
+ Calculate closure phases for numpy arrays of 3 baselines that should form a closed triangle:
229
+
230
+ .. math::
231
+
232
+ \\phi_{abc} = \\mathrm{arg}(V_{ab}V_{bc}V_{ca})
233
+
234
+ :param img: ``(n, n)``
235
+ Numpy image array.
236
+ :param dx: scalar
237
+ Pixel size of image in microarcseconds.
238
+ :param uvTriangle: ``(num_measurements, 3, 2)``
239
+ The uv samples for which to calculate the closure phase. The baseline order along axis=1 should be (ab,bc,ca).
240
+
241
+ :returns: ``(num_measurements, )``
242
+ Closure phases in radians.
243
+ '''
244
+
245
+ # case where we are only given one time sample
246
+ if len(uvTriangle.shape) == 2: uvTriangle = uvTriangle[newaxis,:]
247
+
248
+ cp = []
249
+ for triang in uvTriangle:
250
+ v = [self.FTElementFast(img,dx,t) for t in triang]
251
+ cp.append(angle(prod(v)))
252
+
253
+ return asarray(cp)
254
+
255
+ def calculateBS(self,img,dx,uvTriangle):
256
+ '''
257
+ Calculate bispectrum for numpy arrays of 3 baselines that should form a closed triangle:
258
+
259
+ .. math::
260
+
261
+ B_{abc} = V_{ab}V_{bc}V_{ca}
262
+
263
+ :param img: ``(n, n)``
264
+ Image array.
265
+ :param dx: scalar
266
+ Pixel size of image in microarcseconds.
267
+ :param uvTriangle: ``(num_measurements, 3, 2)``
268
+ uv samples for which to calculate the bispectra. The baseline order along axis=1 should be (ab,bc,ca).
269
+
270
+ :returns: ``(num_measurements, )``
271
+ Complex bispectra.
272
+ '''
273
+
274
+ # case where we are only given one time sample
275
+ if len(uvTriangle.shape) == 2: uvTriangle = uvTriangle[newaxis,:]
276
+
277
+ bs = []
278
+ for triang in uvTriangle:
279
+ v = [self.FTElementFast(img,dx,t) for t in triang]
280
+ bs.append(prod(v))
281
+
282
+ return asarray(bs)
283
+
284
+ def generateQuadrilateralTracks(self,sites,times=(0.,24.,int((24.-0.)*60./10.)),return_hour=False):
285
+ '''
286
+ Generates a quadruplet of uv points for a quadrilateral (a,b,c,d) ensuring that all sites can see the source. See the docstring for :func:`generateTracks` for notes about the sampling rate.
287
+
288
+ **Suggested usage:** Feed the generated `uv` samples to :func:`calculateCA` which will return the closure amplitude quantity.
289
+
290
+ :param sites: ``(4, 3)``
291
+ Array of geocentric positions for 4 sites (a,b,c,d).
292
+ :param times: (optional) ``(start, stop, num_samples)``
293
+ Tuple setting the spacing of the uv-samples in time. The ``start`` and ``stop`` times should be in hours and in the range [0,24]. ``stop`` is non-inclusive.
294
+ :param return_hour: (optional)
295
+ Return hour of uv samples as second item to unpack.
296
+
297
+ :returns: ``(num_measurements, 4, 2)``
298
+ Array where the order of the baselines along the first dimension (axis=1) is (ab, cd, ac, bd).
299
+ '''
300
+
301
+ assert len(sites) == 4, "sites should form a quadrilateral"
302
+ times = linspace(times[0],times[1],num=times[2],endpoint=False)
303
+ uv = []
304
+ hr = []
305
+ for h in times:
306
+ theta = self.theta(h)
307
+ # zenith cut
308
+ if self._RRelevcut(sites[0],theta) and \
309
+ self._RRelevcut(sites[1],theta) and \
310
+ self._RRelevcut(sites[2],theta) and \
311
+ self._RRelevcut(sites[3],theta):
312
+ quad = (self.generateUV((sites[0],sites[1]),theta),self.generateUV((sites[2],sites[3]),theta),\
313
+ self.generateUV((sites[0],sites[2]),theta),self.generateUV((sites[1],sites[3]),theta))
314
+ uv.append(quad)
315
+ if return_hour: hr.append(h)
316
+
317
+ if return_hour:
318
+ return (array(uv),array(hr))
319
+ else:
320
+ return asarray(uv)
321
+
322
+ def generateTriangleTracks(self,sites,times=(0.,24.,int((24.-0.)*60./10.)),return_hour=False):
323
+ '''
324
+ Generates a triplet of uv points for a closed triangle of sites (a,b,c) ensuring that all sites can see the source.
325
+ See the docstring for :func:`generateTracks` for notes about the sampling rate.
326
+
327
+ **Suggested usage:** Feed the generated UV samples to :func:`calculateBS` or :func:`calculateCP` to calculate closure phase or bispectrum quanities.
328
+
329
+ :param sites: ``(3, 3)``
330
+ Array of geocentric positions for 3 sites (a,b,c).
331
+ :param times: (optional) ``(start, stop, num_samples)``
332
+ Tuple setting the spacing of the uv-samples in time. The ``start`` and ``stop`` times should be in hours and in the range [0,24]. ``stop`` is non-inclusive.
333
+ :param return_hour: (optional)
334
+ Return hour of uv samples as second item to unpack.
335
+
336
+ :returns: ``(num_measurements, 3, 2)``
337
+ Array of uv points where the order of the baselines along the first dimension (axis=1) is (ab,bc,ca).
338
+ '''
339
+ assert len(sites) == 3, "sites should form a triangle"
340
+
341
+ times = linspace(times[0],times[1],num=times[2],endpoint=False)
342
+ uv = []
343
+ hr = []
344
+ for h in times:
345
+ theta = self.theta(h)
346
+ # zenith cut
347
+ if self._RRelevcut(sites[0],theta) and \
348
+ self._RRelevcut(sites[1],theta) and \
349
+ self._RRelevcut(sites[2],theta):
350
+ triang = array([ self.generateUV((sites[0],sites[1]),theta),\
351
+ self.generateUV((sites[1],sites[2]),theta),\
352
+ self.generateUV((sites[2],sites[0]),theta) ])
353
+ uv.append(triang)
354
+ if return_hour: hr.append(h)
355
+
356
+ if return_hour:
357
+ return (array(uv),array(hr))
358
+ else:
359
+ return asarray(uv)
360
+
361
+ def calculateVisibilities(self,img,dx,uv):
362
+ '''
363
+ Calculate complex visibilities.
364
+
365
+ :param img: Image array.
366
+ :param dx: Pixel size in microarcseconds.
367
+ :param uv: ``(num_measurements, 2)`` An array of `uv` samples with dimension.
368
+
369
+ :returns: ``(num_measurements, )`` Scalar complex visibilities.
370
+ '''
371
+
372
+ # case of only 1 uv point
373
+ if len(uv.shape) == 1: uv = uv[newaxis,:]
374
+
375
+ num_samples = uv.shape[0]
376
+ V = zeros(num_samples,dtype=complex64)
377
+ for i in range(num_samples):
378
+ V[i] = self.FTElementFast(img,dx,uv[i,:])
379
+ return V
380
+
381
+ def FTElementFast(self,img,dx,baseline):
382
+ '''
383
+ Return complex visibility.
384
+
385
+ :param img: Image array.
386
+ :param dx: pixel size in microarcseconds.
387
+ :param baseline: (u,v) point in wavelengths.
388
+
389
+ :returns: complex visibility
390
+
391
+ '''
392
+ nx = img.shape[-1]
393
+ du = 3600.*1e6/(nx*dx*radians(1.))
394
+ ind = arange(nx)
395
+ return sum(img * dot(\
396
+ transpose([exp(-2*pi*1j/du/nx*baseline[1]*flipud(ind))]),\
397
+ [exp(-2*pi*1j/du/nx*baseline[0]*ind)]))
@@ -0,0 +1,190 @@
1
+
2
+ """
3
+ .. module:: utilities
4
+ :platform: Unix
5
+ :synopsis: Helpful function for ScatterBrane
6
+
7
+ .. moduleauthor:: Katherine Rosenfeld <krosenf@gmail.com>
8
+ .. moduleauthor:: Michael Johnson
9
+
10
+ """
11
+ from __future__ import print_function
12
+
13
+ import numpy as np
14
+ from scipy.interpolate import RectBivariateSpline
15
+ from scipy.ndimage.filters import gaussian_filter
16
+ from astropy.io import fits
17
+
18
+ def smoothImage(img,dx,fwhm):
19
+ '''
20
+ Returns Image smoothed by a gaussian kernel.
21
+
22
+ :param img: ``(n, n)``
23
+ numpy array
24
+ :param dx: scalar
25
+ Pixel scale in microarcseconds
26
+ :param fwhm: scalar
27
+ Gaussian full width at half maximum in microarcseconds
28
+ '''
29
+ return gaussian_filter(img,fwhm/(2*np.sqrt(np.log(4)))/dx)
30
+
31
+ def getCoherenceLength(theta,wavelength=1.3e-3,magnification=0.448):
32
+ '''
33
+ :param theta: scalar
34
+ FWHM of scattering kernel at 1 cm in milli-arcseconds.
35
+ :param wavelength: (optional) scalar
36
+ Observing wavelength in meters
37
+ :param magnification: (optional) scalar
38
+ Magnification factor (scatterer-observer)/(source-scatterer).
39
+
40
+ :returns: scalar
41
+ Coherence length in km.
42
+ '''
43
+ #return (wavelength*1e-3)*np.sqrt(np.log(4))/(np.pi*np.sqrt(1+magnification)**2*np.radians(1e-3/3600*theta*(wavelength*1e2)**2))
44
+ return (wavelength*1e-3)*np.sqrt(np.log(4))/(np.pi*(1+magnification)*np.radians(1e-3/3600*theta*(wavelength*1e2)**2))
45
+
46
+ def ensembleSmooth(img,dx,brane,return_kernel=False):
47
+ '''
48
+ Generates ensemble averaged image given scattering kernel parameters.
49
+
50
+ :param img: ``(n, n)``
51
+ numpy array
52
+ :param dx: scalar
53
+ Pixel scale in microarcseconds
54
+ :param brane: Brane object.
55
+ :param return_kernel: (optional) bool
56
+ Return tuple with uv kernel (:func:`nump.fft.rfft2` format). See :func:`getUVKernel` for an alternate method.
57
+ '''
58
+ nx = img.shape[0]
59
+
60
+ # scattering kernel parameters in wavelengths
61
+ sigma_maj = brane.wavelength*np.sqrt(np.log(4)) / (np.pi*(1.+brane.m)*brane.r0) / (2*np.sqrt(np.log(4)))
62
+ sigma_min = sigma_maj / brane.anisotropy
63
+ v = np.dot(np.transpose([np.fft.fftfreq(nx,d=dx*np.radians(1.)/(3600*1e6))]),[np.ones(nx/2 + 1)])
64
+ u = np.dot(np.transpose([np.ones(nx)]),[np.fft.rfftfreq(nx,d=dx*np.radians(1.)/(3600*1e6))])
65
+
66
+ # rotate
67
+ if brane.pa != None:
68
+ theta = np.radians(90-brane.pa)
69
+ else:
70
+ theta = np.radians(0.)
71
+ u_ = np.cos(theta)*u - np.sin(theta)*v
72
+ v = np.sin(theta)*u + np.cos(theta)*v
73
+
74
+ # rotate
75
+ G = np.exp(-2*np.pi**2*(u_**2*sigma_maj**2 + v**2*sigma_min**2))
76
+ V = np.fft.rfft2(img)
77
+
78
+ if return_kernel:
79
+ return (np.fft.irfft2(V*G,s=img.shape),G)
80
+ else:
81
+ return np.fft.irfft2(V*G,s=img.shape)
82
+
83
+ def getUVKernel(u,v,brane):
84
+ '''
85
+ Get ensemble kernel in visibility plane for specified uv points. See func:`ensembleSmooth` for an althernate method.
86
+
87
+ :param u: ``(n, )``
88
+ Samples of u in units of wavelengths.
89
+ :param v: ``(n, )``
90
+ Samples of v in units of wavelengths.
91
+ :param brane: Brane object
92
+
93
+ :returns: ``(n, )`` Ensemble kernel complex visibility
94
+ '''
95
+ # scattering kernel parameters in wavelengths
96
+ sigma_maj = brane.wavelength*np.sqrt(np.log(4)) / (np.pi*(1.+brane.m)*brane.r0) / (2*np.sqrt(np.log(4)))
97
+ sigma_min = sigma_maj / brane.anisotropy
98
+
99
+ # rotate
100
+ if brane.pa != None:
101
+ theta = np.radians(90-brane.pa)
102
+ else:
103
+ theta = np.radians(0.)
104
+
105
+ u_ = np.cos(theta)*u - np.sin(theta)*v
106
+ v_ = np.sin(theta)*u + np.cos(theta)*v
107
+
108
+ # rotate and return
109
+ return np.exp(-2*np.pi**2*(u_**2*sigma_maj**2 + v_**2*sigma_min**2))
110
+
111
+
112
+ def loadSettings(filename):
113
+ '''
114
+ Loads simulation settings from a file generated by :func:`Brane.save_settings`.
115
+
116
+ :param filename: string
117
+ File name that contains simulation settings.
118
+
119
+ :returns: A dictionary with simulation settings.
120
+
121
+ '''
122
+ return dict(np.genfromtxt(filename,\
123
+ dtype=[('a','|S10'),('f','float')],delimiter='\t',autostrip=True))
124
+
125
+ def regrid(a,inx,idx,onx,odx):
126
+ '''
127
+ Regrids array with a new resolution and pixel number.
128
+
129
+ :param a: ``(n, n)``
130
+ Input numpy image
131
+ :param inx: int
132
+ Number of input pixels on a side
133
+ :param idx: scalar
134
+ Input resolution element
135
+ :param onx: int
136
+ Number of output pixels on a side
137
+ :param odx: scalar
138
+ Output resolution element
139
+
140
+ :returns: Array regridded to the new resolution and field of view.
141
+ '''
142
+ x = idx * (np.arange(inx) - 0.5 * (inx - 1))
143
+ f = RectBivariateSpline(x,x,a)
144
+ x_ = odx * (np.arange(onx) - 0.5 * (onx - 1))
145
+ xx_,yy_ = np.meshgrid(x_,x_,indexing='xy')
146
+ m = f.ev(yy_.flatten(),xx_.flatten()).reshape((onx,onx))
147
+ return m*(odx/idx)**2
148
+
149
+ def writefits(m,dx,dest='image.fits',obsra=266.4168370833333,obsdec=-29.00781055555555,freq=230e9):
150
+ '''
151
+ Write fits file with header. Defaults are set for Sgr A* at 1.3mm.
152
+
153
+ :param m: ``(n, n)``
154
+ numpy image array
155
+ :param dx: scalar
156
+ Pixel size in microarcseconds
157
+ :param dest: (optional) string
158
+ Output fits file name
159
+ :param obsra: (optional) scalar
160
+ Source right ascension
161
+ :param obsdec: (optional) scalar
162
+ Source declination
163
+ '''
164
+ hdu = fits.PrimaryHDU(m)
165
+ hdu.header['CDELT1'] = -1*dx*np.radians(1.)/(3600.*1e6)
166
+ hdu.header['CDELT2'] = dx*np.radians(1.)/(3600.*1e6)
167
+ hdu.header['OBSRA'] = obsra
168
+ hdu.header['OBSDEC'] = obsdec
169
+ hdu.header['FREQ'] = freq
170
+ hdu.writeto(dest,clobber=True)
171
+
172
+ def FTElementFast(img,dx,baseline):
173
+ '''
174
+ Return complex visibility.
175
+
176
+ :param img: ``(n, n)``
177
+ numpy image array
178
+ :param dx: scalar
179
+ Pixel size in microarcseconds
180
+ :param baseline: ``(2, )``
181
+ (u,v) point in wavelengths
182
+
183
+ .. note:: To shift center try multipliny by :math:`\\mathrm{exp}(\\pi i u n_x\\Delta_x)` and watch out for the axis orientation.
184
+ '''
185
+ nx = img.shape[-1]
186
+ du = 1./(nx * dx * np.radians(1.)/(3600*1e6))
187
+ ind = np.arange(nx)
188
+ return np.sum(img * np.dot(\
189
+ np.transpose([np.exp(-2j*np.pi/du/nx*baseline[1]*np.flipud(ind))]),\
190
+ [np.exp(-2j*np.pi/du/nx*baseline[0]*ind)]))
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: scatterbrane
3
+ Version: 0.1.0
4
+ Summary: A python module to simulate the effect of anisotropic scattering on astrophysical images.
5
+ Author-email: Katherine Rosenfeld <krosenf@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, http://krosenfeld.github.io/scatterbrane/
8
+ Keywords: scattering,astronomy,EHT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/x-rst
17
+ License-File: LICENSE
18
+ Requires-Dist: astropy
19
+ Requires-Dist: imageio
20
+ Requires-Dist: matplotlib
21
+ Requires-Dist: numpy
22
+ Requires-Dist: scipy
23
+ Dynamic: license-file
24
+
25
+ scatterbrane
26
+ ============
27
+
28
+ A python module for adding realistic scattering to astronomical images. This version
29
+ represents a very early release so please submit a pull request if you have trouble or
30
+ need help for your application.
31
+
32
+ For installation instructions and help getting started please see `the documentation <http://krosenfeld.github.io/scatterbrane/>`_.
33
+
34
+ Installation
35
+ ------------
36
+
37
+ This project now uses ``pyproject.toml`` and `uv <https://docs.astral.sh/uv/>`_ for dependency management.
38
+
39
+ Install the package and its runtime dependencies with:
40
+
41
+ .. code-block:: bash
42
+
43
+ uv sync
44
+
45
+ Install the release and development tooling with:
46
+
47
+ .. code-block:: bash
48
+
49
+ uv sync --group dev
50
+
51
+ Release workflow
52
+ ----------------
53
+
54
+ Version management is handled with ``bump-my-version``. To create a new tagged release:
55
+
56
+ .. code-block:: bash
57
+
58
+ uv run bump-my-version bump patch
59
+
60
+ This updates the package version, creates a commit, and creates a ``vX.Y.Z`` git tag. Pushing that tag to GitHub triggers the publish workflow for PyPI.
61
+
62
+ Reference
63
+ ---------
64
+
65
+ `Johnson, M. D., & Gwinn, C. R. 2015, ApJ, 805, 180 <http://adsabs.harvard.edu/abs/2015ApJ...805..180J>`_
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.rst
3
+ pyproject.toml
4
+ scatterbrane/__init__.py
5
+ scatterbrane/brane.py
6
+ scatterbrane/tracks.py
7
+ scatterbrane/utilities.py
8
+ scatterbrane.egg-info/PKG-INFO
9
+ scatterbrane.egg-info/SOURCES.txt
10
+ scatterbrane.egg-info/dependency_links.txt
11
+ scatterbrane.egg-info/requires.txt
12
+ scatterbrane.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ astropy
2
+ imageio
3
+ matplotlib
4
+ numpy
5
+ scipy
@@ -0,0 +1 @@
1
+ scatterbrane
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+