From 1ed2bb981dd9c064d0d37ad48686e3f7bd9288e1 Mon Sep 17 00:00:00 2001 From: hamogu Date: Sun, 30 Aug 2020 23:29:49 -0400 Subject: [PATCH 1/5] convert all inputs for sources to quantities --- marxs/source/labSource.py | 15 +- marxs/source/source.py | 168 ++++++++++--------- marxs/source/tests/test_farLabPointSource.py | 8 +- marxs/source/tests/test_labPointSource.py | 14 +- marxs/source/tests/test_pointing.py | 8 +- marxs/source/tests/test_source.py | 114 ++++++------- 6 files changed, 172 insertions(+), 155 deletions(-) diff --git a/marxs/source/labSource.py b/marxs/source/labSource.py index 5e97f31..022cb0b 100644 --- a/marxs/source/labSource.py +++ b/marxs/source/labSource.py @@ -1,6 +1,7 @@ # Licensed under GPL version 3 - see LICENSE.rst import numpy as np from astropy.table import Column +import astropy.units as u import transforms3d from ..optics.base import FlatOpticalElement @@ -28,9 +29,12 @@ class FarLabPointSource(Source, FlatOpticalElement): ''' def __init__(self, sourcePos, **kwargs): self.sourcePos = sourcePos - super(FarLabPointSource, self).__init__(**kwargs) + if not 'flux' in kwargs: + kwargs['flux'] = 1 / u.s + super(FarLabPointSource, self).__init__(geomarea=None, **kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input + def generate_photons(self, exposuretime: u.s): photons = super(FarLabPointSource, self).generate_photons(exposuretime) n = len(photons) # randomly choose direction - photons uniformly distributed over aperture area @@ -80,9 +84,12 @@ def __init__(self, position=[0, 0, 0], half_opening=np.pi, self.dir = e2h(np.asanyarray(direction) / np.linalg.norm(direction), 0) self.position = e2h(np.asanyarray(position), 1) self.half_opening = half_opening - super(LabPointSourceCone, self).__init__(**kwargs) + if not 'flux' in kwargs: + kwargs['flux'] = 1 / u.s + super(LabPointSourceCone, self).__init__(geomarea=None, **kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input + def generate_photons(self, exposuretime: u.s): photons = super(LabPointSourceCone, self).generate_photons(exposuretime) n = len(photons) diff --git a/marxs/source/source.py b/marxs/source/source.py index 6f7dfae..a38a06b 100644 --- a/marxs/source/source.py +++ b/marxs/source/source.py @@ -21,51 +21,56 @@ from .. import __version__ as marxsversion -def poisson_process(rate): +@u.quantity_input +def poisson_process(rate: (u.s * u.cm**2)**(-1)): '''Return a function that generates Poisson distributed times with rate ``rate``. Parameters ---------- - rate : float - Expectation value for the total rate of events in photons / cm**2 / s. + rate : `~astropy.units.quantity.Quantity` + Expectation value for the total rate of photons with unit 1 / cm**2 / s. Returns ------- poisson_rate : function Function that generates Poisson distributed times with rate ``rate``. ''' - if not np.isscalar(rate): + if not rate.isscalar: raise ValueError('"rate" must be scalar.') - def poisson_rate(exposuretime, geomarea): + @u.quantity_input(exposuretime=u.s) + def poisson_rate(exposuretime: u.s, geomarea: u.cm**2) -> u.s: '''Generate Poisson distributed times. Parameters ---------- - exposuretime : float - Exposure time in sec. - geomarea : `astropy.unit.Quantity` + exposuretime : `~astropy.units.quantity.Quantity` + Exposure time + geomarea : `~astropy.units.quantity.Quantity` Geometric opening area of telescope Returns ------- - times : `numpy.ndarray` + times : `~astropy.units.quantity.Quantity` Poisson distributed times. ''' - fullrate = rate * geomarea.to(u.cm**2).value + fullrate = (rate * geomarea).to(u.s**(-1)).value # Make 10 % more numbers then we expect to need, because it's random - times = expon.rvs(scale=1./fullrate, size=int(exposuretime * fullrate * 1.1)) + times = expon.rvs(scale=1./fullrate, + size=int(exposuretime.value * fullrate * 1.1)) # If we don't have enough numbers right now, add some more. - while times.sum() < exposuretime: + while times.sum() < exposuretime.value: times = np.hstack([times, expon.rvs(scale=1/fullrate, - size=int(exposuretime - times.sum() * fullrate * 1.1))]) + size=int(exposuretime.value - times.sum() * fullrate * 1.1))]) times = np.cumsum(times) - return times[times < exposuretime] + return times[times < exposuretime.value] * u.s return poisson_rate + class SourceSpecificationError(Exception): pass + class Source(SimulationSequenceElement): '''Base class for all photons sources. @@ -79,27 +84,26 @@ class Source(SimulationSequenceElement): Parameters ---------- - flux : number or callable This sets the total flux from a source in - photons/s/cm^2; the default value is 1 cts/s. Options are: + flux : `~astropy.units.quantity.Quantity` or callable + This sets the total flux from a source in + photons/time/area. Options are: - - number: Constant (not Poisson distributed) flux. + - quantity: Constant (not Poisson distributed) flux. - callable: Function that takes a total exposure time as input and returns an array of photon emission times between 0 and the total exposure time. - energy : number of callable or (2, N) `numpy.ndarray` or `numpy.recarray` or `dict ` or `astropy.table.Table` + energy : polarization. + - `~astropy.units.quantity.Quantity` of callable or `astropy.table.Table` - This input decides the energy of the emitted photons; - the default value is 1 keV. + This input decides the energy of the emitted photons. Possible formats are: - - number: Constant energy. - - (2, N) `numpy.ndarray` or object with columns "energy" and "flux" - (e.g. `dict ` or `astropy.table.Table`), + - polarization. + - `~astropy.units.quantity.Quantity`: Constant energy. + - `astropy.table.Table`: where "flux" here really is a short form for "flux density" and is given in the units of photons/s/keV. - For a (2, N) array the first column is the energy, the - second column is the flux density. Given this table, the code assumes a piecewise flat spectrum. The "energy" values contain the **upper** limit of each bin, the "flux" array the flux density in each bin. @@ -110,23 +114,21 @@ class Source(SimulationSequenceElement): customization. The function must take an array of photon times as input and return an equal length array of photon energies in keV. - polarization: contant or ``None``, (2, N) `numpy.ndarray`, `dict `, `astropy.table.Table` or similar or callable. + polarization : `~astropy.units.quantity.Quantity` or ``None``, `astropy.table.Table` or callable. There are several different ways to set the polarization angle of the photons for a polarized source. In all cases, the angle is measured North through East. (We ignore the special case of a polarized source exactly on a pole.) The default value is ``None`` (unpolarized source). - - ``None``: An unpolarized source. Every photons is assigned a random + - ``None`` : An unpolarized source. Every photons is assigned a random polarization. - - number: Constant polarization angle for all photons - (in degrees). - - (2, N) `numpy.ndarray` or object with columns - "angle" and "probability" (e.g. `dict ` or - `astropy.table.Table`), where "probability" really means "probability + - `~astropy.units.quantity.Quantity` : + Constant polarization angle for all photons + - `~astropy.table.Table` : + "angle" and "probability", where "probability" really means "probability density". The summed probability density will automatically be - normalized to one. For a (2, N) array the first column is the angle, - the second column is the probability *density*. Given this table, the + normalized to one. Given this table, the code assumes a piecewise constant probability density. The "angle" values contain the **upper** limit of each bin, the "probability" array the probability density in this bin. The first entry in the @@ -137,30 +139,35 @@ class Source(SimulationSequenceElement): arrays (time and energy values) as input and must return an array of equal length that contains the polarization angles in degrees. - geomarea : `astropu.units.Quantity` Geometric opening area of - telescope. Default is :math:`1 cm^2`. + geomarea : `astropy.units.Quantity` or ``None``: + Geometric opening area of telescope. If ``None`` then the flux must + be given in photons per time, not per time per unit area. ''' - def __init__(self, **kwargs): - self.energy = kwargs.pop('energy', 1.) - self.flux = kwargs.pop('flux', 1.) - self.polarization = kwargs.pop('polarization', None) - self.geomarea = kwargs.pop('geomarea', 1. * u.cm**2) + def __init__(self, energy=1*u.keV, flux=1 / u.s / u.cm**2, + polarization=None, geomarea=1*u.cm**2, **kwargs): + self.energy = energy + self.flux = flux + self.polarization = polarization + self.geomarea = 1 if geomarea is None else geomarea super(Source, self).__init__(**kwargs) def __call__(self, *args, **kwargs): return self.generate_photons(*args, **kwargs) - def generate_times(self, exposuretime): + @u.quantity_input() + def generate_times(self, exposuretime: u.s): if callable(self.flux): return self.flux(exposuretime, self.geomarea) - elif np.isscalar(self.flux): - return np.arange(0, exposuretime, 1./(self.flux * self.geomarea.to(u.cm**2).value)) + elif hasattr(self.flux, 'isscalar') and self.flux.isscalar: + return np.arange(0, exposuretime.to(u.s).value, + 1. / (self.flux * self.geomarea * u.s).decompose()) * u.s else: raise SourceSpecificationError('`flux` must be a quantity or a callable.') - def generate_energies(self, t): + @u.quantity_input + def generate_energies(self, t: u.s) -> u.keV: n = len(t) # function if callable(self.energy): @@ -169,23 +176,21 @@ def generate_energies(self, t): raise SourceSpecificationError('`energy` has to return an array of same size as input time array.') else: return en - # constant energy - elif np.isscalar(self.energy): - return np.ones(n) * self.energy - # 2 * n numpy array - elif hasattr(self.energy, 'shape') and (self.energy.shape[0] == 2): - rand = RandomArbitraryPdf(self.energy[0, :], self.energy[1, :]) - return rand(n) - # np.recarray or astropy.table.Table - elif hasattr(self.energy, '__getitem__'): - rand = RandomArbitraryPdf(self.energy['energy'], self.energy['flux']) - return rand(n) + # astropy.table.QTable + elif hasattr(self.energy, 'columns'): + rand = RandomArbitraryPdf(self.energy['energy'].to(u.keV).value, + self.energy['flux'].to((u.s * u.cm**2)**(-1)).value) + return rand(n) * u.keV + # scalar quantity + elif hasattr(self.energy, 'isscalar') and self.energy.isscalar: + return np.ones(n) * self.energy.to(u.keV, + equivalencies=u.spectral()) # anything else else: - raise SourceSpecificationError('`energy` must be number, function, 2*n array or have fields "energy" and "flux".') + raise SourceSpecificationError('`energy` must be Quantity, function, or have columns "energy" and "flux".') - def generate_polarization(self, times, energies): + def generate_polarization(self, times: u.s, energies: u.keV) -> u.rad: n = len(times) # function if callable(self.polarization): @@ -194,51 +199,56 @@ def generate_polarization(self, times, energies): raise SourceSpecificationError('`polarization` has to return an array of same size as input time and energy arrays.') else: return pol - elif np.isscalar(self.polarization): - return np.ones(n) * self.polarization - # 2 * n numpy array - elif hasattr(self.polarization, 'shape') and (self.polarization.shape[0] == 2): - rand = RandomArbitraryPdf(self.polarization[0, :], self.polarization[1, :]) - return rand(n) - # np.recarray or astropy.table.Table - elif hasattr(self.polarization, '__getitem__'): - rand = RandomArbitraryPdf(self.polarization['angle'], self.polarization['probability']) - return rand(n) elif self.polarization is None: - return np.random.uniform(0, 2 * np.pi, n) + return np.random.uniform(0, 2 * np.pi, n) * u.rad + # astropy.table.QTable + elif hasattr(self.polarization, 'columns'): + rand = RandomArbitraryPdf(self.polarization['angle'].to(u.rad).value, + self.polarization['probability']) + return rand(n) * u.rad + # scalar quantity + elif hasattr(self.polarization, 'isscalar') and self.polarization.isscalar: + return np.ones(n) * self.polarization else: raise SourceSpecificationError('`polarization` must be number (angle), callable, None (unpolarized), 2.n array or have fields "angle" (in rad) and "probability".') def generate_photon(self): raise NotImplementedError - def generate_photons(self, exposuretime): + @u.quantity_input() + def generate_photons(self, exposuretime: u.s): '''Central function to generate photons. - Calling this function generates a a photon table according to the `flux`, `energy`, - and `polarization` of this source. The number of photons depends on the total - exposure time, which is a parameter of this function. Depending on the setting for - `flux` the photons could be distributed equally over the interval 0..exposuretime - or follow some other distribution. + Calling this function generates a photon table according to + the `flux`, `energy`, and `polarization` of this source. The + number of photons depends on the total exposure time, which is + a parameter of this function. Depending on the setting for + `flux` the photons could be distributed equally over the + interval 0..exposuretime or follow some other distribution. Parameters ---------- - exposuretime : float - Total exposure time in seconds. + exposuretime : `astropy.quantity.Quantity` + Total exposure time. Returns ------- photons : `astropy.table.Table` Table with photon properties. + ''' times = self.generate_times(exposuretime) energies = self.generate_energies(times) pol = self.generate_polarization(times, energies) n = len(times) - photons = Table([times, energies, pol, np.ones(n)], + photons = Table([times.to(u.s).value, + energies.to(u.keV).value, + pol.to(u.rad).value, + np.ones(n)], names=['time', 'energy', 'polangle', 'probability']) photons.meta['EXTNAME'] = 'EVENTS' - photons.meta['EXPOSURE'] = (exposuretime, 'total exposure time [s]') + photons.meta['EXPOSURE'] = (exposuretime.to(u.s), + 'total exposure time [s]') #photons.meta['DATE-OBS'] = photons.meta['CREATOR'] = 'MARXS - Version {0}'.format(marxsversion) diff --git a/marxs/source/tests/test_farLabPointSource.py b/marxs/source/tests/test_farLabPointSource.py index 39536b8..5607fc6 100644 --- a/marxs/source/tests/test_farLabPointSource.py +++ b/marxs/source/tests/test_farLabPointSource.py @@ -1,5 +1,6 @@ # Licensed under GPL version 3 - see LICENSE.rst import numpy as np +import astropy.units as u from ..labSource import FarLabPointSource as LabSource @@ -8,11 +9,12 @@ def test_photon_generation(): the starting positions of the photons are within the aperture. ''' pos = [1., 0., 0.] - rate = 10 + rate = 10 / u.s center = [0., 0.5, 0.] - source = LabSource(pos, flux=rate, energy=5., position = center, zoom = [1., 1., 2.8]) + source = LabSource(pos, flux=rate, energy=5. * u.keV, + position = center, zoom = [1., 1., 2.8]) - photons = source.generate_photons(1.) + photons = source.generate_photons(1. * u.s) for i in range (0, 10): assert photons['pos'][i][1] >= -0.5 assert photons['pos'][i][1] <= 1.5 diff --git a/marxs/source/tests/test_labPointSource.py b/marxs/source/tests/test_labPointSource.py index 6fd9813..05232e4 100644 --- a/marxs/source/tests/test_labPointSource.py +++ b/marxs/source/tests/test_labPointSource.py @@ -2,6 +2,7 @@ import numpy as np import transforms3d import pytest +import astropy.units as u from ..labSource import LabPointSourceCone @@ -17,10 +18,10 @@ def test_photon_generation(): are all at the sources position. ''' pos = [1., 1., 1.] - rate = 10 - source = LabPointSourceCone(position=pos, flux=rate, energy=5.) + rate = 10 / u.s + source = LabPointSourceCone(position=pos, flux=rate, energy=5. * u.keV) - photons = source.generate_photons(1.) + photons = source.generate_photons(1. * u.s) assert np.all(photons['pos'] == np.ones([10, 4])) @@ -32,13 +33,14 @@ def test_directions_range_cone(): # parameters pos = [5 * np.random.random(), 5 * np.random.random(), 5 * np.random.random()] - rate = 10 * np.random.random() + rate = 10 * np.random.random() / u.s direction = [5* np.random.random(),5* np.random.random(),5* np.random.random()] delta = ((np.pi / 2) * (np.random.random() * 2 - 1)) # run simulation - source = LabPointSourceCone(position=pos, half_opening= delta, flux=rate, energy=5., direction = direction) - photons = source.generate_photons(1.) + source = LabPointSourceCone(position=pos, half_opening=delta, flux=rate, + energy=5. * u.keV, direction = direction) + photons = source.generate_photons(1. * u.s) # norm direction diff --git a/marxs/source/tests/test_pointing.py b/marxs/source/tests/test_pointing.py index f916645..e7310a4 100644 --- a/marxs/source/tests/test_pointing.py +++ b/marxs/source/tests/test_pointing.py @@ -13,7 +13,7 @@ def test_reference_coordiante_system(): '''Usually, rays that are on-axis will come in along the x-axis. Test a simulations with a different coordinate system.''' s = PointSource(coords=SkyCoord(12., 34., unit='deg')) - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) point_x = FixedPointing(coords=SkyCoord(12., 34., unit='deg')) p_x = point_x(photons.copy()) assert np.allclose(p_x['dir'].data[:, 0], -1.) @@ -28,7 +28,7 @@ def test_reference_coordiante_system(): def test_polarization_direction(): '''Test that a FixedPointing correctly assigns linear polarization vectors.''' s = PointSource(coords=SkyCoord(187.4, 0., unit='deg')) - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) photons['polangle'] = (np.array([0., 90., 180., 270., 45.])) * u.deg.to(photons['polangle'].unit) point_x = FixedPointing(coords=SkyCoord(187.4, 0., unit='deg')) p_x = point_x(photons.copy()) @@ -51,7 +51,7 @@ def test_polarization_direction(): # Photons pointing east with the same RA will have parallel polarization vectors # This is true for all pointing directions. s = PointSource(coords=SkyCoord(22.5, 0., unit='deg')) - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) photons['dec'] = [67., 23., 0., -45.454, -67.88] photons['polangle'] = (90. * u.deg).to(photons['polangle'].unit) point = FixedPointing(coords=SkyCoord(94.3, 23., unit='deg')) @@ -106,7 +106,7 @@ def test_jitter(): def test_polarization_perpendicular(pointing): '''Consistency: Polarization vector must always be perpendicular to dir.''' s = PointSource(coords=SkyCoord(0., 0., unit='deg')) - photons = s.generate_photons(10) + photons = s.generate_photons(10 * u.s) photons['ra'] = np.random.uniform(0, 360., len(photons)) # Exclude +-90 deg, because not handling poles well photons['dec'] = np.random.uniform(-89.9, 89.9, len(photons)) diff --git a/marxs/source/tests/test_source.py b/marxs/source/tests/test_source.py index d0ed8b2..63a870f 100644 --- a/marxs/source/tests/test_source.py +++ b/marxs/source/tests/test_source.py @@ -2,7 +2,7 @@ import numpy as np import pytest from scipy.stats import normaltest -from astropy.table import Table +from astropy.table import Table, QTable from astropy.coordinates import SkyCoord import astropy.units as u @@ -20,14 +20,14 @@ def test_photons_header(): that I need a full list here. ''' s = SymbolFSource(coords=SkyCoord(-123., -43., unit=u.deg), size=1.*u.deg) - photons = s.generate_photons(5.) + photons = s.generate_photons(5. * u.s) for n in ['EXPOSURE', 'CREATOR', 'MARXSVER', 'SIMTIME', 'SIMUSER']: assert n in photons.meta def test_energy_input_default(): '''For convenience and testing, defaults for time, energy and pol are set.''' s = Source() - photons = s.generate_photons(5.) + photons = s.generate_photons(5. * u.s) assert len(photons) == 5 assert np.all(photons['energy'] == 1.) assert len(set(photons['polangle'])) == 5 # all pol angles are different @@ -35,109 +35,105 @@ def test_energy_input_default(): def test_flux_input(): '''Options: contant rate or function''' # 1. constant rate - s = Source(flux=5) - photons = s.generate_photons(5.) + s = Source(flux=5 / u.hour / u.cm**2) + photons = s.generate_photons(5. * u.h) assert len(photons) == 25 delta_t = np.diff(photons['time']) assert np.allclose(delta_t, delta_t[0]) # constante rate # 2. function - def f(t, a): - return np.logspace(1, np.log10(t)) + def f(t, geomarea): + return np.logspace(1, np.log10(t.to(u.s).value)) * u.s s = Source(flux=f) - photons = s.generate_photons(100) + photons = s.generate_photons(100 * u.s) assert np.all(photons['time'] == np.logspace(1, 2)) # 3. anything else s = Source(flux=ValueError()) with pytest.raises(SourceSpecificationError) as e: - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert '`flux` must be' in str(e.value) def test_energy_input(): '''Many different options ...''' # 1. contant energy - s = PointSource(coords=SkyCoord("1h12m43.2s +1d12m43s"), energy=2.) - photons = s.generate_photons(5) + s = PointSource(coords=SkyCoord("1h12m43.2s +1d12m43s"), energy=2. * u.keV) + photons = s.generate_photons(5 * u.minute) assert np.all(photons['energy'] == 2.) # 2. function def f(t): - return t + return (t / u.s * u.keV).decompose() s = Source(energy=f) - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert np.all(photons['energy'] == photons['time']) # bad function - s = Source(energy=lambda x: np.array([np.sum(x)])) + s = Source(energy=lambda x: np.array([np.sum(x.value)]) * u.erg) with pytest.raises(SourceSpecificationError) as e: - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert 'an array of same size as' in str(e.value) # 3. array, table, recarray, dict, ... just try some of of the opion with keys # spectrum with distinct lines - engrid = [0.5, 1., 2., 3.] - fluxgrid = [456., 1., 0., 2.] # first entry (456) will be ignored - s1 = Source(energy={'energy': engrid, 'flux': fluxgrid}) - s2 = Source(energy=np.vstack([engrid, fluxgrid])) - s3 = Source(energy=Table({'energy': engrid, 'flux': fluxgrid})) - for s in [s1, s2, s3]: - photons = s.generate_photons(1000) - en = photons['energy'] - ind0510 = (en >= 0.5) & (en <=1.0) - ind2030 = (en >= 2.) & (en <=3.0) - assert (ind0510.sum() + ind2030.sum()) == len(photons) - assert ind0510.sum() < ind2030.sum() + engrid = [0.5, 1., 2., 3.] * u.keV + # first entry (456) will be ignored + fluxgrid = [456., 1., 0., 2.] / u.s / u.cm**2 + s = Source(energy=QTable({'energy': engrid, 'flux': fluxgrid})) + + photons = s.generate_photons(1000 * u.s) + en = photons['energy'] + ind0510 = (en >= 0.5) & (en <=1.0) + ind2030 = (en >= 2.) & (en <=3.0) + assert (ind0510.sum() + ind2030.sum()) == len(photons) + assert ind0510.sum() < ind2030.sum() # 4. anything else s = Source(energy=object()) with pytest.raises(SourceSpecificationError) as e: - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert '`energy` must be' in str(e.value) def test_polarization_input(): '''Many differnet options ...''' # 1. 100 % polarized flux - s = Source(polarization=2.) - photons = s.generate_photons(5) - assert np.all(photons['polangle'] == 2.) + s = Source(polarization=90 * u.deg) + photons = s.generate_photons(5 * u.s) + assert np.all(photons['polangle'] == np.pi / 2) # 2. function def f(t, en): - return t * en - s = Source(polarization=f, energy=2.) - photons = s.generate_photons(5) + return (t / u.s * en / u.keV * u.rad).decompose() + s = Source(polarization=f, energy=2. * u.keV) + photons = s.generate_photons(5 * u.s) assert np.all(photons['polangle'] == photons['time'] * photons['energy']) # bad function - s = Source(polarization=lambda x, y: np.array([np.dot(x, y)])) + s = Source(polarization=lambda x, y: [np.dot(x.value, y.value)] * u.deg) with pytest.raises(SourceSpecificationError) as e: - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert 'an array of same size as' in str(e.value) - # 3. array, table, recarray, dict, ... just try some of of the opion with keys + # 3.table # spectrum with distinct lines - polgrid = [0.5, 1., 2., 3.] + polgrid = [0.5, 1., 2., 3.] * u.rad probgrid = [456., 1., 0., 2.] # first entry (456) will be ignored - s1 = Source(polarization={'angle': polgrid, 'probability': probgrid}) - s2 = Source(polarization=np.vstack([polgrid, probgrid])) - s3 = Source(polarization=Table({'angle': polgrid, 'probability': probgrid})) - for s in [s1, s2, s3]: - photons = s.generate_photons(1000) - pol = photons['polangle'] - ind0510 = (pol >= 0.5) & (pol <=1.0) - ind2030 = (pol >= 2.) & (pol <=3.0) - assert (ind0510.sum() + ind2030.sum()) == len(photons) - assert ind0510.sum() < ind2030.sum() + s = Source(polarization=QTable({'angle': polgrid, 'probability': probgrid})) + photons = s.generate_photons(1000 * u.s) + pol = photons['polangle'] + ind0510 = (pol >= 0.5) & (pol <=1.0) + ind2030 = (pol >= 2.) & (pol <=3.0) + assert (ind0510.sum() + ind2030.sum()) == len(photons) + assert ind0510.sum() < ind2030.sum() # 4. None (unpolarized source) s = Source(polarization=None) - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert len(set(photons['polangle'])) == len(photons) # all different # 5. anything else s = Source(polarization=object()) with pytest.raises(SourceSpecificationError) as e: - photons = s.generate_photons(5) + photons = s.generate_photons(5 * u.s) assert '`polarization` must be' in str(e.value) def test_poisson_process(): @@ -146,26 +142,26 @@ def test_poisson_process(): It turns out that this is hard to test properly, without reimplemention the scipy version. ''' - p = poisson_process(20.) - times = p(100., 1. * u.cm**2) + p = poisson_process(20. / (u.s * u.cm**2)) + times = p(100. * u.s, 1. * u.cm**2).value assert (len(times) > 1500) and (len(times) < 2500) assert (times[-1] > 99.) and (times[-1] < 100.) - times = p(100., 10. * u.cm**2) + times = p(100. * u.s, 10. * u.cm**2).value assert (len(times) > 18000) and (len(times) < 22000) assert (times[-1] > 99.) and (times[-1] < 100.) def test_poisson_input(): '''Input must be a scalar.''' with pytest.raises(ValueError) as e: - p = poisson_process(np.arange(5)) + p = poisson_process(np.arange(5) / u.s / u.cm**2) assert 'must be scalar' in str(e.value) def test_Aeff(): '''Check that a higher effective area leads to more counts.''' a = RectangleAperture(zoom=2) - s = Source(flux=50, geomarea=a.area) - photons = s.generate_photons(5.) + s = Source(flux=50 / u.s / u.cm**2, geomarea=a.area) + photons = s.generate_photons(5. * u.s) assert len(photons) == 40 def test_disk_radius(): @@ -173,7 +169,7 @@ def test_disk_radius(): s = DiskSource(coords=pos, a_inner=50.* u.arcsec, a_outer=10. * u.arcmin) - photons = s.generate_photons(1e4) + photons = s.generate_photons(1e4 * u.s) d = pos.separation(SkyCoord(photons['ra'], photons['dec'], unit='deg')) assert np.max(d.arcmin <= 10.) assert np.min(d.arcmin >= 0.8) @@ -183,7 +179,7 @@ def test_sphericaldisk_radius(): s = SphericalDiskSource(coords=pos, a_inner=50.* u.arcsec, a_outer=10. * u.arcmin) - photons = s.generate_photons(1e4) + photons = s.generate_photons(1e4 * u.s) d = pos.separation(SkyCoord(photons['ra'], photons['dec'], unit='deg')) assert np.max(d.arcmin <= 10.) assert np.min(d.arcmin >= 0.8) @@ -208,7 +204,7 @@ def test_disk_distribution(diskclass, diskpar, n_expected): ''' s = diskclass(coords=SkyCoord(213., -10., unit=u.deg), **diskpar) - photons = s.generate_photons(1e5) + photons = s.generate_photons(1e5 * u.s) n = np.empty(20) for i in range(len(n)): From f471b09e46dacc710c14e2a538b8439f3941f9dc Mon Sep 17 00:00:00 2001 From: hamogu Date: Sun, 30 Aug 2020 23:34:45 -0400 Subject: [PATCH 2/5] add quantiy_input decorator for all generate_photons --- marxs/source/source.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/marxs/source/source.py b/marxs/source/source.py index a38a06b..62d73f2 100644 --- a/marxs/source/source.py +++ b/marxs/source/source.py @@ -166,7 +166,7 @@ def generate_times(self, exposuretime: u.s): else: raise SourceSpecificationError('`flux` must be a quantity or a callable.') - @u.quantity_input + @u.quantity_input() def generate_energies(self, t: u.s) -> u.keV: n = len(t) # function @@ -189,7 +189,7 @@ def generate_energies(self, t: u.s) -> u.keV: else: raise SourceSpecificationError('`energy` must be Quantity, function, or have columns "energy" and "flux".') - + @u.quantity_input() def generate_polarization(self, times: u.s, energies: u.keV) -> u.rad: n = len(times) # function @@ -316,6 +316,7 @@ def set_pos(self, photons, coo): photons['dec'].unit = u.degree photons.meta['COORDSYS'] = ('ICRS', 'Type of coordinate system') + class PointSource(AstroSource): '''Astrophysical point source. @@ -328,7 +329,8 @@ class PointSource(AstroSource): def __init__(self, **kwargs): super(PointSource, self).__init__(**kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input + def generate_photons(self, exposuretime: u.s): photons = super(PointSource, self).generate_photons(exposuretime) self.set_pos(photons, self.coords) return photons @@ -355,7 +357,8 @@ def __init__(self, **kwargs): self.func_par = kwargs.pop('func_par', None) super(RadialDistributionSource, self).__init__(**kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input + def generate_photons(self, exposuretime: u.s): '''Photon positions are generated in a frame that is centered on the coordinates set in ``coords``, then they get transformed into the global sky system. @@ -372,6 +375,7 @@ def generate_photons(self, exposuretime): return photons + class SphericalDiskSource(RadialDistributionSource): '''Astrophysical source with the shape of a circle or ring. @@ -402,6 +406,7 @@ def radial_distribution(self, n): u = np.random.rand(n) return np.arccos(np.cos(self.func_par[1]) * (1. - u) + u * np.cos(self.func_par[0])) + class DiskSource(RadialDistributionSource): '''Astrophysical source with the shape of a circle or ring. @@ -422,6 +427,7 @@ def __init__(self, **kwargs): np.random.rand(n) * (self.func_par[0]**2 - self.func_par[1]**2)) super(DiskSource, self).__init__(**kwargs) + class GaussSource(AstroSource): '''Astrophysical source with a Gaussian brightness profile. @@ -437,7 +443,8 @@ def __init__(self, **kwargs): self.sigma = kwargs.pop('sigma') super(GaussSource, self).__init__(**kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input() + def generate_photons(self, exposuretime: u.s): '''Photon positions are generated in a frame that is centered on the coordinates set in ``coords``, then they get transformed into the global sky system. @@ -472,7 +479,8 @@ def __init__(self, **kwargs): self.size = kwargs.pop('size', 1. * u.degree) super(SymbolFSource, self).__init__(**kwargs) - def generate_photons(self, exposuretime): + @u.quantity_input() + def generate_photons(self, exposuretime: u.s): photons = super(SymbolFSource, self).generate_photons(exposuretime) n = len(photons) elem = np.random.choice(3, size=n) From be30a25910fc14520534567955d5ef3b16a6090e Mon Sep 17 00:00:00 2001 From: hamogu Date: Mon, 31 Aug 2020 19:01:08 -0400 Subject: [PATCH 3/5] apply changes to all other tests --- docs/examples.rst | 3 +- docs/newopticalelements.rst | 3 +- docs/pyplots/runexample.py | 17 +++++---- docs/pyplots/sourcespectrum.py | 26 +++++-------- docs/pyplots/vis_pol.py | 3 +- docs/pyplots/vis_subaperturing.py | 4 +- docs/results.rst | 5 ++- docs/runexample.rst | 10 +++-- docs/source.rst | 38 ++++++++++--------- docs/visualization.rst | 6 +-- marxs/design/tests/test_design.py | 9 +++-- marxs/design/tests/test_tolerancing.py | 2 +- marxs/design/tolerancing.py | 2 +- marxs/missions/chandra/tests/test_chandra.py | 8 ++-- marxs/missions/chandra/tests/test_hrma.py | 4 +- marxs/optics/mirror.py | 3 +- marxs/optics/tests/test_all_optics.py | 4 +- marxs/optics/tests/test_marx.py | 5 ++- marxs/optics/tests/test_mirror.py | 3 +- marxs/optics/tests/test_optics.py | 5 ++- marxs/simulator/simulator.py | 5 ++- marxs/source/source.py | 39 +++++++++++--------- marxs/source/tests/test_source.py | 20 +++++----- marxs/tests/test_analysis.py | 4 +- marxs/tests/test_src_mir_det.py | 5 ++- 25 files changed, 124 insertions(+), 109 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 5e5a074..1283d04 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -53,6 +53,7 @@ We now turn the combination of X-ray source and first mirror around the y-axis ( .. doctest-requires:: mayavi + >>> import astropy.units as u >>> # Make an object that keeps the photon position after every simulation step >>> # for later plotting of photon paths. >>> pos = KeepCol('pos') @@ -66,7 +67,7 @@ We now turn the combination of X-ray source and first mirror around the y-axis ( ... light.position = np.dot(rotmat, light_pos) ... light.dir = np.dot(rotmat, light_dir) ... m1.geometry.pos4d = np.dot(rotmat, m1pos4d) - ... rays = light(100) + ... rays = light(100 * u.s) ... pos.data = [] ... pos(rays) ... rays = experiment(rays) diff --git a/docs/newopticalelements.rst b/docs/newopticalelements.rst index 5cac5b1..b00996c 100644 --- a/docs/newopticalelements.rst +++ b/docs/newopticalelements.rst @@ -10,8 +10,9 @@ First, let us generate some photons coming from some astrophysical source:: >>> from astropy.coordinates import SkyCoord >>> from marxs import source, optics + >>> import astropy.units as u >>> mysource = source.PointSource(coords=SkyCoord(181., -23., unit='deg')) - >>> photons = mysource.generate_photons(10) + >>> photons = mysource.generate_photons(10 * u.s) >>> mypointing = source.FixedPointing(coords=SkyCoord('12h01m23s -22d59m12s')) >>> photons = mypointing(photons) diff --git a/docs/pyplots/runexample.py b/docs/pyplots/runexample.py index 5c68ada..11b6973 100644 --- a/docs/pyplots/runexample.py +++ b/docs/pyplots/runexample.py @@ -1,6 +1,8 @@ import numpy as np from matplotlib import pyplot as plt from astropy.coordinates import SkyCoord +import astropy.units as u +from astropy.table import QTable from marxs.source import poisson_process from marxs.missions import chandra @@ -8,21 +10,22 @@ ngc1313_X1 = SkyCoord("3h18m19.99s -66d29m10.97s") -energies = np.arange(.3, 8., .01) -spectrum1 = 6.3e-4 * energies**(-1.9) -flux1 = (spectrum1[1:] * np.diff(energies)).sum() -flux1 = poisson_process(flux1) +energies = np.arange(.3, 8., .01) * u.keV +fluxdensity = 6.3e-4 * energies.value**(-1.9) / u.s / u.cm**2 / u.keV +fluxperbin = fluxdensity[1:] * np.diff(energies) +flux = poisson_process(fluxperbin.sum()) +energytab = QTable({'energy': energies, 'fluxdensity': fluxdensity}) aperture = chandra.Aperture() -src1 = PointSource(coords=ngc1313_X1, energy={'energy': energies, 'flux': spectrum1}, - flux=flux1, geomarea=aperture.area) +src1 = PointSource(coords=ngc1313_X1, energy=energytab, + flux=flux, geomarea=aperture.area) pointing = chandra.LissajousDither(coords=ngc1313_X1) hrma = chandra.HRMA() acis = chandra.ACIS(chips=[4,5,6,7,8,9], aimpoint=chandra.AIMPOINTS['ACIS-S']) -photons = src1.generate_photons(5e3) # 5 ks exposure time +photons = src1.generate_photons(5 * u.ks) photons = pointing(photons) photons = aperture(photons) photons = hrma(photons) diff --git a/docs/pyplots/sourcespectrum.py b/docs/pyplots/sourcespectrum.py index 90f498b..1345799 100644 --- a/docs/pyplots/sourcespectrum.py +++ b/docs/pyplots/sourcespectrum.py @@ -1,26 +1,20 @@ import numpy as np import matplotlib.pyplot as plt -from astropy.table import Table +from astropy.table import QTable +import astropy.units as u from marxs.source import Source -en = np.arange(0.5, 7., .5) -flux = en**(-2) +en = np.arange(0.5, 7., .5) * u.keV +fluxperbin = en.value**(-2) / u.s / u.cm**2 / u.keV -# Input as dictionary -dictspectrum = {'energy': en, 'flux': flux} -s1 = Source(energy=dictspectrum, name='dict') +# Input as astropy QTable +tablespectrum = QTable([en, fluxperbin], names=['energy', 'fluxdensity']) +s = Source(energy=tablespectrum, name='table') -# Input as astropy Table -tablespectrum = Table([en, flux], names=['energy', 'flux']) -s2 = Source(energy=tablespectrum, name='table') - -# Input as numpy arrays -numpyspectrum = np.vstack([en, flux]) -s3 = Source(energy=numpyspectrum, name='2d array') fig = plt.figure() -for s in [s1, s2, s3]: - photons = s.generate_photons(1e3) - plt.hist(photons['energy'], histtype='step', label=s.name, bins=20) + +photons = s.generate_photons(1e3 * u.s) +plt.hist(photons['energy'], histtype='step', label=s.name, bins=20) leg = plt.legend() lab = plt.xlabel('Energy [keV]') diff --git a/docs/pyplots/vis_pol.py b/docs/pyplots/vis_pol.py index 7ae68e6..350ecab 100644 --- a/docs/pyplots/vis_pol.py +++ b/docs/pyplots/vis_pol.py @@ -1,6 +1,7 @@ import numpy as np from transforms3d import euler from astropy.io import ascii +import astropy.units as u import matplotlib.pyplot as plt from marxs.source import LabPointSourceCone @@ -34,7 +35,7 @@ light.position = np.dot(rotmat, light_pos) light.dir = np.dot(rotmat, light_dir) m1.geometry.pos4d = np.dot(rotmat, m1pos4d) - rays = light(10000) + rays = light(10000 * u.s) rays = experiment(rays) n_detected[i] = rays['probability'].sum() diff --git a/docs/pyplots/vis_subaperturing.py b/docs/pyplots/vis_subaperturing.py index b5687d9..80140ed 100644 --- a/docs/pyplots/vis_subaperturing.py +++ b/docs/pyplots/vis_subaperturing.py @@ -46,10 +46,10 @@ def specific_process_photons(self, *args, **kwargs): instrum = simulator.Sequence(elements=[aper, mirr, gas, det, projectfp]) star = source.PointSource(coords=SkyCoord(30., 30., unit='deg'), - energy=1., flux=1.) + energy=1. * u.keV, flux=1. / u.s / u.cm**2) pointing = source.FixedPointing(coords=SkyCoord(30., 30., unit='deg')) -photons = star.generate_photons(4000) +photons = star.generate_photons(4000 * u.s) photons = pointing(photons) photons = instrum(photons) ind = (photons['probability'] > 0) & (photons['facet'] >=0) diff --git a/docs/results.rst b/docs/results.rst index d19063a..cafed3d 100644 --- a/docs/results.rst +++ b/docs/results.rst @@ -14,11 +14,12 @@ The most important columns during the simultion are the homogeneous space positi Photons from astrophysical sources have the follwoing properties: Time, energy, true sky coordinates (RA and dec) and polarization angle (measures North through east). There is also a probability column (see below), which is initialized to one:: >>> from astropy.coordinates import SkyCoord + >>> import astropy.units as u >>> from marxs import optics, source >>> coords = SkyCoord(345., -35., unit='deg') - >>> src = source.PointSource(coords=coords, energy=0.25) + >>> src = source.PointSource(coords=coords, energy=0.25 * u.keV) >>> point = source.FixedPointing (coords=coords) - >>> photons = src.generate_photons(5) + >>> photons = src.generate_photons(5 * u.s) >>> photons.colnames ['time', 'energy', 'polangle', 'probability', 'ra', 'dec'] >>> photons['probability'] diff --git a/docs/runexample.rst b/docs/runexample.rst index db06d4f..81ebfd8 100644 --- a/docs/runexample.rst +++ b/docs/runexample.rst @@ -20,8 +20,10 @@ In many cases, the spectrum of the sources will come from a table that is genera Our source here has a simple power-low spectrum. We ignore extinction for the moment which would significantly reduce the flux we observe in the soft band. >>> import numpy as np - >>> energies = np.arange(.3, 8., .01) - >>> spectrum1 = 6.3e-4 * energies**(-1.9) + >>> import astropy.units as u + >>> from astropy.table import QTable + >>> energies = np.arange(.3, 8., .01) * u.keV + >>> spectrum1 = 6.3e-4 * energies.value**(-1.9) / u.s / u.cm**2 / u.keV Marxs sources have separate parameters for the ``energy`` (shape of the spectrum) and the ``flux`` (normalization of the spectrum and temporal distribution). In many cases we want to apply the same spectrum with different normalizations to different sources, so it is useful to separate the shape of the spectrum and the total flux. We simulate a source that is constant in time, so the photon arrival times will just be Poisson distributed. In this case, the input spectrum is already properly normalized, so we just sum it all up to to get the total flux. Note that MARXS interprets the energy array as the *upper* energy bound for each bin. The lower energy bound is gives as the upper energy bound from the previous bin. This leaves the *lower* bound of the first bin undefined and thus we do not include it in the sum. @@ -37,7 +39,7 @@ The last thing that the source needs to know is how large the geometric opening Now, can can finally create our source: >>> from marxs.source import PointSource - >>> src1 = PointSource(coords=ngc1313_X1, energy={'energy': energies, 'flux': spectrum1}, + >>> src1 = PointSource(coords=ngc1313_X1, energy=QTable({'energy': energies, 'fluxdensity': spectrum1}), ... flux=flux1, geomarea=aperture.area) See :ref:`sources` for more details. @@ -63,7 +65,7 @@ Run the simulation ================== In MARXS, a source generates a photon table and all following elements process this photon table by changing values in a column or adding new columns. Thus, we can stop and inspect the photons after every step (e.g. to look at their position right after they exit the mirror) or just execute all steps in a row:: - >>> photons = src1.generate_photons(5e3) # 5 ks exposure time + >>> photons = src1.generate_photons(5 * u.ks) >>> photons = pointing(photons) >>> photons = aperture(photons) >>> photons = hrma(photons) diff --git a/docs/source.rst b/docs/source.rst index 8292bc9..b1dee25 100644 --- a/docs/source.rst +++ b/docs/source.rst @@ -31,13 +31,14 @@ The source flux, the energy and the polarization of sources are specified in the Flux ---- -The source flux can just be a number, giving the total counts / second / mm^2 (if no number is given, the default is ``flux=1``). +The source flux can just be a number with units:: >>> from __future__ import print_function >>> from marxs.source import PointSource >>> from astropy.coordinates import SkyCoord - >>> star = PointSource(coords=SkyCoord("23h12m2.3s -3d4m12.3s"), flux=5.) - >>> photons = star.generate_photons(20) + >>> import astropy.units as u + >>> star = PointSource(coords=SkyCoord("23h12m2.3s -3d4m12.3s"), flux=5. / u.s / u.cm**2) + >>> photons = star.generate_photons(20 * u.s) >>> photons['time'].format='4.1f' >>> print(photons['time'][:6]) time @@ -56,21 +57,21 @@ This will generate 5 counts per second for 20 seconds with an absolutely constan >>> from scipy.stats import expon >>> def poisson_rate(exposuretime): - ... times = expon.rvs(scale=0.01, size=exposuretime * 0.01 * 2.) - ... return times[times < exposuretime] + ... times = expon.rvs(scale=0.01, size=exposuretime.to(u.s).value * 0.01 * 2.) + ... return times[times < exposuretime] * u.s >>> star = PointSource(coords=SkyCoord(0, 0, unit="deg"), flux=poisson_rate) Note that this simple implementation is incomplete (it can happen by chance that it does not generate enough photons). MARXS provides a better implementation called `~marxs.source.poisson_process` which will generate the appropriate function automatically given the expected rate: >>> from marxs.source.source import poisson_process - >>> star = PointSource(coords=SkyCoord("23h12m2.3s -3d4m12.3s"), flux=poisson_process(100.)) + >>> star = PointSource(coords=SkyCoord("23h12m2.3s -3d4m12.3s"), flux=poisson_process(100. / u.s / u.cm**2)) Energy ------ -Similarly to the flux, the input for ``energy`` can just be a number, which specifies the energy of a monochromatic source in keV (the default is ``energy=1``): +Similarly to the flux, the input for ``energy`` can just be a number with a unit, which specifies the energy of a monochromatic source: - >>> FeKalphaline = PointSource(coords=SkyCoord(255., -33., unit="deg"), energy=6.7) - >>> photons = FeKalphaline.generate_photons(5) + >>> FeKalphaline = PointSource(coords=SkyCoord(255., -33., unit="deg"), energy=6.7 * u.keV) + >>> photons = FeKalphaline.generate_photons(5 * u.s) >>> print(photons['energy']) energy keV @@ -90,13 +91,13 @@ Two helpful hints: - If the input spectrum is in some type of file, e.g. fits or ascii, the `astropy.table.Table` `read/write interface `_ offers a convenient way to read it into python:: - >>> from astropy.table import Table - >>> spectrum = Table.read('AGNspec.dat', format='ascii') # doctest: +SKIP + >>> from astropy.table import QTable + >>> spectrum = QTable.read('AGNspec.dat', format='ascii') # doctest: +SKIP >>> agn = PointSource(coords=SkyCoord("11h11m1s -2d3m2.3s", energy=spectrum) # doctest: +SKIP - The normalization of the source flux is always is always given by the ``flux`` parameter, independent of the normalizion of ``spectrum['energy']``. If the input is a table and the flux density in that table is in units of photons/s/cm^2/keV, then it is easy to add all that up to set the flux parameter:: - >>> flux = (spectrum['flux'][1:] * np.diff(spectrum['energy'])).sum() # doctest: +SKIP + >>> flux = (spectrum['fluxdensity'][1:] * np.diff(spectrum['energy'])).sum() # doctest: +SKIP >>> agn = PointSource(coords=SkyCoord("11h11m1s -2d3m2.3s", energy=spectrum, ... flux=flux) # doctest: +SKIP @@ -106,12 +107,13 @@ Lastly, "energy" can be a function that assigns energy values based on the timin >>> from marxs.source.source import Source >>> import numpy as np >>> def time_dependent_energy(t): + ... t = t.value # convert quantity to plain numpy array ... en = np.ones_like(t) ... en[t <= 5.] = 0.5 ... en[t > 5.] = 2. - ... return en + ... return en * u.keV >>> mysource = Source(energy=time_dependent_energy) - >>> photons = mysource.generate_photons(7) + >>> photons = mysource.generate_photons(7 * u.s) >>> print(photons['time', 'energy']) time energy s keV @@ -131,9 +133,9 @@ the default). In this case, a random polarization is assigned to every photon. The other options are very similar to "energy": Allowed are a constant angle (in degrees) or a table of some form (see examples above) with two columns "angle" and "probability" (really "probability density") or a numpy array where the first column represents the angle and the second one the probability density. Here is an example where most polarizations are randomly oriented, but an orientation around :math:`35^{\circ}` (0.6 in radian) is a lot more likely. - >>> angles = np.array([0., 30., 40., 360]) - >>> prob = np.array([1, 1., 8., 1.]) - >>> polsource = PointSource(coords=SkyCoord(11., -5.123, unit='deg'), polarization={'angle': angles, 'probability': prob}) + >>> angles = np.array([0., 30., 40., 360]) * u.degree + >>> prob = np.array([1, 1., 8., 1.]) / u.degree + >>> polsource = PointSource(coords=SkyCoord(11., -5.123, unit='deg'), polarization=QTable({'angle': angles, 'probabilitydensity': prob})) Lastly, if polarization is a function, it will be called with time and energy as parameters allowing for time and energy dependent polarization @@ -145,7 +147,7 @@ the 6.4 keV Fe flourescence line after some polarized feature comes into view at ... ind = (time > 1000.) & (energy > 6.3) & (energy < 6.5) ... # set half of all photons with these conditions to a specific polarization angle ... pol[ind & (np.random.rand(len(time))> 0.5)] = 1.234 - ... return pol + ... return pol * u.rad >>> polsource = Source(energy=tablespectrum, polarization=polfunc) # doctest: +SKIP diff --git a/docs/visualization.rst b/docs/visualization.rst index 016d9a0..a8bf636 100644 --- a/docs/visualization.rst +++ b/docs/visualization.rst @@ -104,14 +104,14 @@ Another change compared to the 2d plotting is that we generate a lot fewer photo >>> import numpy as np >>> from astropy.coordinates import SkyCoord + >>> import astropy.units as u >>> from marxs import source, simulator >>> # object to save intermediate photons positions after every step of the simulaion >>> pos = simulator.KeepCol('pos') >>> instrum = simulator.Sequence(elements=[aper, mirr, gas, det, projectfp], postprocess_steps=[pos]) - >>> star = source.PointSource(coords=SkyCoord(30., 30., unit='deg'), - ... energy=1., flux=1.) + >>> star = source.PointSource(coords=SkyCoord(30., 30., unit='deg')) >>> pointing = source.FixedPointing(coords=SkyCoord(30., 30., unit='deg')) - >>> photons = star.generate_photons(100) + >>> photons = star.generate_photons(100 * u.s) >>> photons = pointing(photons) >>> photons = instrum(photons) >>> ind = (photons['probability'] > 0) & (photons['facet'] >=0) diff --git a/marxs/design/tests/test_design.py b/marxs/design/tests/test_design.py index ae30e68..a8fc300 100644 --- a/marxs/design/tests/test_design.py +++ b/marxs/design/tests/test_design.py @@ -4,6 +4,7 @@ import transforms3d import pytest from astropy.coordinates import SkyCoord +import astropy.units as u from ..rowland import (RowlandTorus, GratingArrayStructure, LinearCCDArray, RowlandCircleArray, @@ -19,8 +20,8 @@ class mock_facet(FlatOpticalElement): pass def test_radius_of_photon_shell(): - mysource = PointSource(coords=SkyCoord(30., 30., unit="deg"), flux=1., energy=1.) - photons = mysource.generate_photons(1000) + mysource = PointSource(coords=SkyCoord(30., 30., unit="deg")) + photons = mysource.generate_photons(1. * u.ks) mypointing = FixedPointing(coords=SkyCoord(30, 30., unit='deg')) photons = mypointing.process_photons(photons) marxm = MarxMirror('./marxs/optics/hrma.par', position=np.array([0., 0, 0])) @@ -298,8 +299,8 @@ def test_run_photons_through_gas(): No need to check here that the grating equation works - that's part of the grating tests/ ''' # Setup only. - mysource = PointSource(coords=SkyCoord(30., 30., unit="deg"), flux=1., energy=1.) - photons = mysource.generate_photons(1000) + mysource = PointSource(coords=SkyCoord(30., 30., unit="deg")) + photons = mysource.generate_photons(1. * u.ks) mypointing = FixedPointing(coords=SkyCoord(30, 30., unit='deg')) photons = mypointing.process_photons(photons) marxm = MarxMirror('./marxs/optics/hrma.par', position=np.array([0., 0, 0])) diff --git a/marxs/design/tests/test_tolerancing.py b/marxs/design/tests/test_tolerancing.py index 3e56bcc..e5cfbac 100644 --- a/marxs/design/tests/test_tolerancing.py +++ b/marxs/design/tests/test_tolerancing.py @@ -239,7 +239,7 @@ def test_run_tolerances_for_energies(): CaptureResAeff(orders=[0, 1, 2]), reset={'period_mean': 0.005, 'period_sigma': 0.}, - t_source=1000) + t_source=1. * u.ks) # Check the reset worked assert grat._d == 0.005 # Check both energy have been calculated diff --git a/marxs/design/tolerancing.py b/marxs/design/tolerancing.py index 424e16e..eec7fb4 100644 --- a/marxs/design/tolerancing.py +++ b/marxs/design/tolerancing.py @@ -413,7 +413,7 @@ def run_tolerances_for_energies(source, energies, outtabs = [] for i, e in enumerate(energies): - source.energy = e.to(u.keV).value + source.energy = e photons_in = source.generate_photons(t_source) photons_in = instrum_before(photons_in) data = run_tolerances(photons_in, instrum_remaining, diff --git a/marxs/missions/chandra/tests/test_chandra.py b/marxs/missions/chandra/tests/test_chandra.py index 721c921..378accd 100644 --- a/marxs/missions/chandra/tests/test_chandra.py +++ b/marxs/missions/chandra/tests/test_chandra.py @@ -34,12 +34,12 @@ def test_ditherpattern(): def test_stationary_pointing(): '''Constant pointing can also be realized through a Lissajous with amplitude=0.''' - mysource = PointSource(coords=SkyCoord(30., 30., unit="deg"), energy=1., flux=1.) + mysource = PointSource(coords=SkyCoord(30., 30., unit="deg")) fixedpointing = FixedPointing(coords=SkyCoord(30., 30., unit=u.degree), roll=15. * u.degree) lisspointing = chandra.LissajousDither(coords=SkyCoord(30., 30., unit=u.degree), roll=15. * u.degree, DitherAmp=np.zeros(3)*u.degree) - photons = mysource.generate_photons(2) + photons = mysource.generate_photons(2 * u.s) fixedphotons = fixedpointing(photons.copy()) lissphotons = lisspointing(photons.copy()) @@ -53,12 +53,12 @@ def test_detector_coordsystems(): The most obvious one is the size of the pixel - currently the number of pixels times the pixel size does now match the length of the chip precisely. ''' - mysource = PointSource(coords=SkyCoord(30., 30., unit="deg"), energy=1., flux=1.) + mysource = PointSource(coords=SkyCoord(30., 30., unit="deg")) mypointing = chandra.LissajousDither(coords=SkyCoord(30., 30., unit='deg'), roll=15. * u.degree) # marxm = MarxMirror('./marxs/optics/hrma.par', position=np.array([0., 0,0])) acis = chandra.ACIS(chips=[0,1,2,3], aimpoint=chandra.AIMPOINTS['ACIS-I']) - photons = mysource.generate_photons(5) + photons = mysource.generate_photons(5 * u.s) photons = mypointing(photons) # We want reproducible direction, so don't use mirror, but set direction by hand # photons = marxm(photons) diff --git a/marxs/missions/chandra/tests/test_hrma.py b/marxs/missions/chandra/tests/test_hrma.py index 3aec7fb..4b8c4c0 100644 --- a/marxs/missions/chandra/tests/test_hrma.py +++ b/marxs/missions/chandra/tests/test_hrma.py @@ -17,7 +17,7 @@ def test_FWHM(): coords = SkyCoord(25., 45., unit=u.deg) src = source.PointSource(coords=coords) pointing = source.FixedPointing(coords=coords) - p = src(1e4) + p = src(10 * u.ks) p = pointing(p) pmarx = marx(p.copy()) ppy = aperpy(p.copy()) @@ -38,7 +38,7 @@ def test_most_photons_hit_grating(): coords = SkyCoord(25., 45., unit=u.deg) src = source.PointSource(coords=coords) pointing = source.FixedPointing(coords=coords) - p = src(1e4) + p = src(10. * u.ks) p = pointing(p) p = aperpy(p.copy()) p = mirrpy(p) diff --git a/marxs/optics/mirror.py b/marxs/optics/mirror.py index 7f740fd..28bb7ea 100644 --- a/marxs/optics/mirror.py +++ b/marxs/optics/mirror.py @@ -58,13 +58,14 @@ class ThinLens(FlatOpticalElement): >>> import matplotlib.pyplot as plt >>> from astropy.coordinates import SkyCoord + >>> import astropy.units as u >>> from marxs import source, optics >>> mysource = source.PointSource(coords=SkyCoord(30., 30., unit="deg")) >>> mypointing = source.FixedPointing(coords=SkyCoord(30., 30., unit='deg')) >>> myslit = optics.RectangleAperture(zoom=2) >>> lens = optics.ThinLens(focallength=10,zoom=40) - >>> photons = mysource.generate_photons(11) + >>> photons = mysource.generate_photons(11 * u.s) >>> photons = mypointing(photons) >>> photons = myslit(photons) >>> photons = lens(photons) diff --git a/marxs/optics/tests/test_all_optics.py b/marxs/optics/tests/test_all_optics.py index 62484be..028549a 100644 --- a/marxs/optics/tests/test_all_optics.py +++ b/marxs/optics/tests/test_all_optics.py @@ -101,8 +101,8 @@ # Make a test photon list # Some of this should be separate tests, e.g. source position vs. pointing. # Can I vary energy for e.g. grating? -mysource = PointSource(coords=SkyCoord(30., 30., unit='deg'), energy=1., flux=300.) -masterphotons = mysource.generate_photons(11) +mysource = PointSource(coords=SkyCoord(30., 30., unit='deg')) +masterphotons = mysource.generate_photons(500 * u.s) mypointing = FixedPointing(coords=SkyCoord(30., 30., unit='deg')) masterphotons = mypointing(masterphotons) myslit = RectangleAperture() diff --git a/marxs/optics/tests/test_marx.py b/marxs/optics/tests/test_marx.py index 8d6dc58..06d2773 100644 --- a/marxs/optics/tests/test_marx.py +++ b/marxs/optics/tests/test_marx.py @@ -10,6 +10,7 @@ import numpy as np from scipy.stats import ks_2samp from astropy.coordinates import SkyCoord +import astropy.units as u import marxs import marxs.source @@ -20,8 +21,8 @@ def test_noexplicettimedependence(): mirror shell 0, which turned out to be due to ``sorted_index`` being a int, while marx expects an *unsigned* int. ''' - mysource = marxs.source.PointSource(coords=SkyCoord(30., 30., unit="deg"), flux=1., energy=1.) - photons = mysource.generate_photons(1000) + mysource = marxs.source.PointSource(coords=SkyCoord(30., 30., unit="deg")) + photons = mysource.generate_photons(1 * u.ks) mypointing = marxs.source.FixedPointing(coords=SkyCoord(30, 30., unit='deg')) photons = mypointing(photons) diff --git a/marxs/optics/tests/test_mirror.py b/marxs/optics/tests/test_mirror.py index 7e65cf7..e11d5e3 100644 --- a/marxs/optics/tests/test_mirror.py +++ b/marxs/optics/tests/test_mirror.py @@ -2,6 +2,7 @@ import numpy as np import pytest from astropy.coordinates import SkyCoord +import astropy.units as u from ... import source, optics from ...math.utils import h2e @@ -14,7 +15,7 @@ def test_PerfectLens(ra): f = 1000 lens = optics.PerfectLens(focallength=f, zoom=400) - photons = mysource.generate_photons(11) + photons = mysource.generate_photons(11 * u.s) photons = mypointing(photons) photons = myslit(photons) assert np.allclose(h2e(photons['pos'].data)[:, 0], 100.) diff --git a/marxs/optics/tests/test_optics.py b/marxs/optics/tests/test_optics.py index 1b8fd30..5fb0908 100644 --- a/marxs/optics/tests/test_optics.py +++ b/marxs/optics/tests/test_optics.py @@ -3,6 +3,7 @@ from scipy.stats import kstest from astropy.table import Table, Column from astropy.coordinates import SkyCoord +import astropy.units as u from transforms3d.axangles import axangle2aff @@ -18,9 +19,9 @@ @pytest.fixture(autouse=True) def photons1000(): '''Make a list of photons parallel to optical axis''' - mysource = marxs.source.PointSource(coords=SkyCoord(30., 30., unit="deg"), energy=1., flux=300.) + mysource = marxs.source.PointSource(coords=SkyCoord(30., 30., unit="deg")) np.random.seed(0) - p = mysource.generate_photons(1000) + p = mysource.generate_photons(300 * u.ks) mypointing = marxs.source.FixedPointing(coords=SkyCoord(30., 30., unit='deg')) return mypointing(p) diff --git a/marxs/simulator/simulator.py b/marxs/simulator/simulator.py index 5634652..37f8627 100644 --- a/marxs/simulator/simulator.py +++ b/marxs/simulator/simulator.py @@ -126,13 +126,14 @@ class Sequence(BaseContainer): First, we import the required modules: >>> from astropy.coordinates import SkyCoord + >>> import astropy.units as u >>> from marxs import source, optics >>> from marxs.simulator import Sequence Then, we build up the parts of the simulation, source, pointing model and hardware of our instrument: - >>> mysource = source.PointSource(coords=SkyCoord(30., 30., unit="deg"), flux=1e-3, energy=2.) + >>> mysource = source.PointSource(coords=SkyCoord(30., 30., unit="deg"), flux=1e-3 / u.s / u.cm**2, energy=2. * u.keV) >>> sky2mission = source.FixedPointing(coords=SkyCoord(30., 30., unit='deg')) >>> aper = optics.RectangleAperture(position=[50., 0., 0.]) >>> mirr = optics.ThinLens(focallength=10, position=[10., 0., 0.]) @@ -142,7 +143,7 @@ class Sequence(BaseContainer): Finally, we run one set of photons through the instrument: - >>> photons_in = mysource.generate_photons(1e5) + >>> photons_in = mysource.generate_photons(1e5 * u.s) >>> photons_out = my_instrument(photons_in) Now, let us check where the photons fall on the detector: diff --git a/marxs/source/source.py b/marxs/source/source.py index 62d73f2..061d88d 100644 --- a/marxs/source/source.py +++ b/marxs/source/source.py @@ -59,9 +59,9 @@ def poisson_rate(exposuretime: u.s, geomarea: u.cm**2) -> u.s: times = expon.rvs(scale=1./fullrate, size=int(exposuretime.value * fullrate * 1.1)) # If we don't have enough numbers right now, add some more. - while times.sum() < exposuretime.value: + while (times.sum() * u.s) < exposuretime: times = np.hstack([times, expon.rvs(scale=1/fullrate, - size=int(exposuretime.value - times.sum() * fullrate * 1.1))]) + size=int((exposuretime.to(u.s).value - times.sum()) * fullrate * 1.1))]) times = np.cumsum(times) return times[times < exposuretime.value] * u.s return poisson_rate @@ -102,17 +102,16 @@ class Source(SimulationSequenceElement): - polarization. - `~astropy.units.quantity.Quantity`: Constant energy. - `astropy.table.Table`: - where "flux" here really is a short form for - "flux density" and is given in the units of photons/s/keV. Given this table, the code assumes a piecewise flat spectrum. The "energy" values contain the **upper** limit of each bin, - the "flux" array the flux density in each bin. - The first entry in the "flux" array is ignored, because the lower + the "fluxdensity" array the flux density in each bin. + The first entry in the "fluxdensity" array is ignored, because the lower bound of this bin is undefined. The code draws an energy from this spectrum for every photon created. - A function or callable object: This option allows for full customization. The function must take an array of photon times as - input and return an equal length array of photon energies in keV. + input and return an equal length array of photon energies + `~astropy.units.quantity.Quantity`. polarization : `~astropy.units.quantity.Quantity` or ``None``, `astropy.table.Table` or callable. There are several different ways to set the polarization angle of the @@ -126,18 +125,18 @@ class Source(SimulationSequenceElement): - `~astropy.units.quantity.Quantity` : Constant polarization angle for all photons - `~astropy.table.Table` : - "angle" and "probability", where "probability" really means "probability - density". The summed probability density will automatically be + Table with columns called "angle" and "probabilitydensity". + The summed probability density will automatically be normalized to one. Given this table, the code assumes a piecewise constant probability density. The "angle" - values contain the **upper** limit of each bin, the "probability" - array the probability density in this bin. The first entry in the - "probability" array is ignored, because the lower bound of this bin + values contain the **upper** limit of each bin. The first entry in the + "probabilitydenisty" array is ignored, because the lower bound of this bin is undefined. - a callable (function or callable object): This option allows full customization. The function is called with two arrays (time and energy values) as input and must return an array of - equal length that contains the polarization angles in degrees. + equal length that contains the polarization angles as + `~astropy.units.quantity.Quantity` object. geomarea : `astropy.units.Quantity` or ``None``: Geometric opening area of telescope. If ``None`` then the flux must @@ -178,8 +177,10 @@ def generate_energies(self, t: u.s) -> u.keV: return en # astropy.table.QTable elif hasattr(self.energy, 'columns'): - rand = RandomArbitraryPdf(self.energy['energy'].to(u.keV).value, - self.energy['flux'].to((u.s * u.cm**2)**(-1)).value) + x = self.energy['energy'].to(u.keV).value + y = (self.energy['fluxdensity'][1:] * np.diff(self.energy['energy'])).to((u.s * u.cm**2)**(-1)).value + y = np.hstack(([0], y)) + rand = RandomArbitraryPdf(x, y) return rand(n) * u.keV # scalar quantity elif hasattr(self.energy, 'isscalar') and self.energy.isscalar: @@ -187,7 +188,7 @@ def generate_energies(self, t: u.s) -> u.keV: equivalencies=u.spectral()) # anything else else: - raise SourceSpecificationError('`energy` must be Quantity, function, or have columns "energy" and "flux".') + raise SourceSpecificationError('`energy` must be Quantity, function, or have columns "energy" and "fluxdensity".') @u.quantity_input() def generate_polarization(self, times: u.s, energies: u.keV) -> u.rad: @@ -203,8 +204,10 @@ def generate_polarization(self, times: u.s, energies: u.keV) -> u.rad: return np.random.uniform(0, 2 * np.pi, n) * u.rad # astropy.table.QTable elif hasattr(self.polarization, 'columns'): - rand = RandomArbitraryPdf(self.polarization['angle'].to(u.rad).value, - self.polarization['probability']) + x = self.polarization['angle'].to(u.rad).value + y = (self.polarization['probabilitydensity'][1:] * np.diff(self.polarization['angle'])).to(u.dimensionless_unscaled).value + y = np.hstack(([0], y)) + rand = RandomArbitraryPdf(x, y) return rand(n) * u.rad # scalar quantity elif hasattr(self.polarization, 'isscalar') and self.polarization.isscalar: diff --git a/marxs/source/tests/test_source.py b/marxs/source/tests/test_source.py index 63a870f..1e506c8 100644 --- a/marxs/source/tests/test_source.py +++ b/marxs/source/tests/test_source.py @@ -78,8 +78,8 @@ def f(t): # spectrum with distinct lines engrid = [0.5, 1., 2., 3.] * u.keV # first entry (456) will be ignored - fluxgrid = [456., 1., 0., 2.] / u.s / u.cm**2 - s = Source(energy=QTable({'energy': engrid, 'flux': fluxgrid})) + fluxgrid = [456., 1., 0., 2.] / u.s / u.cm**2 / u.keV + s = Source(energy=QTable({'energy': engrid, 'fluxdensity': fluxgrid})) photons = s.generate_photons(1000 * u.s) en = photons['energy'] @@ -116,8 +116,8 @@ def f(t, en): # 3.table # spectrum with distinct lines polgrid = [0.5, 1., 2., 3.] * u.rad - probgrid = [456., 1., 0., 2.] # first entry (456) will be ignored - s = Source(polarization=QTable({'angle': polgrid, 'probability': probgrid})) + probgrid = [456., 1., 0., 2.] / u.rad # first entry (456) will be ignored + s = Source(polarization=QTable({'angle': polgrid, 'probabilitydensity': probgrid})) photons = s.generate_photons(1000 * u.s) pol = photons['polangle'] ind0510 = (pol >= 0.5) & (pol <=1.0) @@ -185,8 +185,8 @@ def test_sphericaldisk_radius(): assert np.min(d.arcmin >= 0.8) @pytest.mark.parametrize("diskclass,diskpar,n_expected", - [(DiskSource, {'a_outer': 30. * u.arcmin}, 2800), - (SphericalDiskSource, {'a_outer': 30. * u.arcmin}, 2800), + [(DiskSource, {'a_outer': 30. * u.arcmin}, 2777), + (SphericalDiskSource, {'a_outer': 30. * u.arcmin}, 2777), (GaussSource, {'sigma': 1.5 * u.deg}, 150)]) def test_disk_distribution(diskclass, diskpar, n_expected): '''This is a separate test from test_disk_radius, because it's a simpler @@ -197,7 +197,7 @@ def test_disk_distribution(diskclass, diskpar, n_expected): That makes testing it a little awkard in a short run time, thus the limits are fairly loose. - This test is run for several extended sources, incl Gaussian. Stirctly speaking + This test is run for several extended sources, incl Gaussian. Strictly speaking it should fail for a Gaussian distribution, but if the sigma is large enough it will pass a loose test (and still fail if things to catastrophically wrong, e.g. some test circles are outside the source). @@ -206,16 +206,16 @@ def test_disk_distribution(diskclass, diskpar, n_expected): s = diskclass(coords=SkyCoord(213., -10., unit=u.deg), **diskpar) photons = s.generate_photons(1e5 * u.s) - n = np.empty(20) + n = np.empty(50) for i in range(len(n)): circ = SkyCoord((213. + np.random.uniform(-0.1, .1)) * u.degree, (- 10. + np.random.uniform(-0.1, .1)) * u.degree) d = circ.separation(SkyCoord(photons['ra'], photons['dec'], unit='deg')) n[i] = (d < 5. * u.arcmin).sum() - s, p = normaltest(n) + # s, p = normaltest(n) # assert a p value here that is soo small that it's never going to be hit # by chance. - assert p > .05 + # assert p > .05 # better: Test number of expected photons matches # Allow large variation so that this is not triggered by chance assert np.isclose(n.mean(), n_expected, rtol=.2) diff --git a/marxs/tests/test_analysis.py b/marxs/tests/test_analysis.py index 185235b..8576976 100644 --- a/marxs/tests/test_analysis.py +++ b/marxs/tests/test_analysis.py @@ -58,9 +58,9 @@ def test_resolvingpower_consistency(): 'orientation': blazemat, 'order_selector': None}, ) - star = PointSource(coords=SkyCoord(23., 45., unit="degree"), flux=5.) + star = PointSource(coords=SkyCoord(23., 45., unit="degree"), flux=5. / u.s / u.cm**2) pointing = FixedPointing(coords=SkyCoord(23., 45., unit='deg')) - photons = star.generate_photons(exposuretime=200) + photons = star.generate_photons(exposuretime=200 * u.s) p = pointing(photons) p = uptomirror(p) diff --git a/marxs/tests/test_src_mir_det.py b/marxs/tests/test_src_mir_det.py index 7ddca45..5e1978c 100644 --- a/marxs/tests/test_src_mir_det.py +++ b/marxs/tests/test_src_mir_det.py @@ -1,5 +1,6 @@ # Licensed under GPL version 3 - see LICENSE.rst import numpy as np +import astropy.units as u from marxs.source.labSource import LabPointSourceCone from marxs.optics.multiLayerMirror import MultiLayerMirror @@ -18,13 +19,13 @@ def test_src_mir_det(): [0, 1, 0], [-1, 0, 0]]) - source = LabPointSourceCone([10., 0., 0.], flux=100., energy=-1.) + source = LabPointSourceCone([10., 0., 0.], flux=100. / u.s) mirror = MultiLayerMirror(reflFile=string1, testedPolarization=string2, position=np.array([0., 0., 0.]), orientation=rotation1) detector = FlatDetector(1., position=np.array([0., 0., 10.]), orientation=rotation2, zoom = np.array([1, 100, 100])) - photons = source.generate_photons(100) + photons = source.generate_photons(100 * u.s) photons = mirror(photons) photons = detector(photons) From f12f58cde3d74216a2f92f04322c67f22b91e898 Mon Sep 17 00:00:00 2001 From: hamogu Date: Mon, 31 Aug 2020 20:16:37 -0400 Subject: [PATCH 4/5] Adjust docs This adresses part of #139. --- CHANGES.rst | 5 +++++ docs/source.rst | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0b888a0..c77fa5f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -34,6 +34,11 @@ New Features API Changes ^^^^^^^^^^^ + +- Sources now require input for flux, energy, and polarization as astropy + quantities. Any table-like input must not be a astropy `QTable` and flux and + polarization values are *densities* not per-bin. [#218] + - The geometry of a class is now defined as a separate object from the new `marxs.math.geometry` module. Before, the geometry was backed into the optical components itself which led to significant code duplication. The new diff --git a/docs/source.rst b/docs/source.rst index b1dee25..be3e42a 100644 --- a/docs/source.rst +++ b/docs/source.rst @@ -19,7 +19,7 @@ MARXS offers many options to specify the flux, spectrum and polarization that ar In addition, we need to give the location of the source and its size and shape (most of the currently implemented sources are point sources, but additional shapes will be added in the future): - **Astrophysical source**: Needs coordiantes on the sky and a pointing model to translate from sky coordinates to the coordiante system of the satellite. See :ref:`sect-source-radec`. -- **Lab source**: Needs x,y,z coordianates in the coordinate system of the experiement as explained in :ref:`pos4d`. Lab source are described in more detail in :ref:`sect-source-lab`. +- **Lab source**: Needs x,y,z coordianates in the coordinate system of the experiement as explained in :ref:`pos4d`. Lab sources are described in more detail in :ref:`sect-source-lab`. .. _sect-source-fluxenpol: @@ -51,7 +51,7 @@ The source flux can just be a number with units:: 0.8 1.0 -This will generate 5 counts per second for 20 seconds with an absolutely constant (no Poisson) rate, so the photon list will contain 100 photons. +This will generate 5 counts per second for 20 seconds with an absolutely constant (no Poisson) rate for a default effective area of :math:`1\;\mathrm{cm}^2`, so the photon list will contain 100 photons. ``flux`` can also be set to a function that takes the total exposure time as input and returns a list of times, one per photon. In the following example we show how to implement a `Poisson process `_ where the time intervals between two photons are `exponentially distributed `_ with an average rate of 100 events per second (so the average time difference between events is 0.01 s): @@ -82,14 +82,14 @@ Similarly to the flux, the input for ``energy`` can just be a number with a unit 6.7 6.7 -We can also specify a spectrum, by giving binned energy and flux density values. The energy values are taken as the *upper* egde of the bin; the first value of the flux density array is ignored since the lower bound for this bin is undefined. The spectrum can either be in the form of a (2, N) `numpy.ndarray` or it can be some type of table, e.g. an `astropy.table.Table` or a `dict ` with columns named "energy" and "flux" (meaning: "flux density" in counts/s/unit area/keV). In the following exmaple, we specify the same spectrum in three differect ways (the plots look a little different because photon energies are randomly drawn from the spectrum, so there is a Poisson uncertainty): +We can also specify a spectrum, by giving binned energy and flux density values. The energy values are taken as the *upper* egde of the bin; the first value of the flux density array is ignored since the lower bound for this bin is undefined. The format of the spectrum should be a `~astropy.table.QTable` with columns named "energy" and "fluxdensity" (photons/s/area/keV): .. plot:: pyplots/sourcespectrum.py :include-source: Two helpful hints: -- If the input spectrum is in some type of file, e.g. fits or ascii, the `astropy.table.Table` `read/write interface `_ offers a convenient way to read it into python:: +- If the input spectrum is in some type of file, e.g. fits or ascii, the `astropy.table.QTable` `read/write interface `_ offers a convenient way to read it into python:: >>> from astropy.table import QTable >>> spectrum = QTable.read('AGNspec.dat', format='ascii') # doctest: +SKIP @@ -131,7 +131,7 @@ Polarization An unpolarized source can be created with ``polarization=None`` (this is also the default). In this case, a random polarization is assigned to every photon. The other options are very similar to "energy": Allowed are a constant -angle (in degrees) or a table of some form (see examples above) with two columns "angle" and "probability" (really "probability density") or a numpy array where the first column represents the angle and the second one the probability density. Here is an example where most polarizations are randomly oriented, but an orientation around :math:`35^{\circ}` (0.6 in radian) is a lot more likely. +angle or a table with columns "angle" and "probabilitydensity". Here is an example where most polarizations are randomly oriented, but an orientation around :math:`35^{\circ}` (0.6 in radian) is a lot more likely. >>> angles = np.array([0., 30., 40., 360]) * u.degree >>> prob = np.array([1, 1., 8., 1.]) / u.degree @@ -144,7 +144,7 @@ the 6.4 keV Fe flourescence line after some polarized feature comes into view at >>> def polfunc(time, energy): ... pol = np.random.uniform(high=2*np.pi, size=len(time)) - ... ind = (time > 1000.) & (energy > 6.3) & (energy < 6.5) + ... ind = (time > 1000. * u.s) & (energy > 6.3 * u.keV) & (energy < 6.5 * u.keV) ... # set half of all photons with these conditions to a specific polarization angle ... pol[ind & (np.random.rand(len(time))> 0.5)] = 1.234 ... return pol * u.rad @@ -156,7 +156,7 @@ the 6.4 keV Fe flourescence line after some polarized feature comes into view at Specify the sky position of an astrophysical source =================================================== -An astrophysical source in Marxs must be followed by a pointing model as first optical element that translates the sky coordiantes into the coordinate system of the satellite (see :ref:`pos4d`) and an entrace aperture that selects an initial position for each ray (all rays from astrophysical sources are parallel, thus the position of the source on the sky only determines the direction of a photon but not if it hits the left or the right side of a mirror). See :ref:`sect-apertures` for more details. +An astrophysical source in |marxs| must be followed by a pointing model as first optical element that translates the sky coordiantes into the coordinate system of the satellite (see :ref:`pos4d`) and an entrace aperture that selects an initial position for each ray (all rays from astrophysical sources are parallel, thus the position of the source on the sky only determines the direction of a photon but not if it hits the left or the right side of a mirror). See :ref:`sect-apertures` for more details. The following astropysical sources are included in marxs: From 90d001b280a1de97ab52f8dcefb12d476e32d024 Mon Sep 17 00:00:00 2001 From: hamogu Date: Tue, 1 Sep 2020 15:04:05 -0400 Subject: [PATCH 5/5] Fix sphinx warning --- docs/source.rst | 2 +- marxs/source/source.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/source.rst b/docs/source.rst index be3e42a..655d9bf 100644 --- a/docs/source.rst +++ b/docs/source.rst @@ -156,7 +156,7 @@ the 6.4 keV Fe flourescence line after some polarized feature comes into view at Specify the sky position of an astrophysical source =================================================== -An astrophysical source in |marxs| must be followed by a pointing model as first optical element that translates the sky coordiantes into the coordinate system of the satellite (see :ref:`pos4d`) and an entrace aperture that selects an initial position for each ray (all rays from astrophysical sources are parallel, thus the position of the source on the sky only determines the direction of a photon but not if it hits the left or the right side of a mirror). See :ref:`sect-apertures` for more details. +An astrophysical source in marxs must be followed by a pointing model as first optical element that translates the sky coordiantes into the coordinate system of the satellite (see :ref:`pos4d`) and an entrace aperture that selects an initial position for each ray (all rays from astrophysical sources are parallel, thus the position of the source on the sky only determines the direction of a photon but not if it hits the left or the right side of a mirror). See :ref:`sect-apertures` for more details. The following astropysical sources are included in marxs: diff --git a/marxs/source/source.py b/marxs/source/source.py index 061d88d..78ee3a4 100644 --- a/marxs/source/source.py +++ b/marxs/source/source.py @@ -85,7 +85,7 @@ class Source(SimulationSequenceElement): Parameters ---------- flux : `~astropy.units.quantity.Quantity` or callable - This sets the total flux from a source in + This sets the total flux from a source in photons/time/area. Options are: - quantity: Constant (not Poisson distributed) flux. @@ -93,8 +93,7 @@ class Source(SimulationSequenceElement): returns an array of photon emission times between 0 and the total exposure time. - energy : polarization. - - `~astropy.units.quantity.Quantity` of callable or `astropy.table.Table` + energy : `~astropy.units.quantity.Quantity` or callable or `~astropy.table.QTable` This input decides the energy of the emitted photons. Possible formats are: @@ -113,7 +112,7 @@ class Source(SimulationSequenceElement): input and return an equal length array of photon energies `~astropy.units.quantity.Quantity`. - polarization : `~astropy.units.quantity.Quantity` or ``None``, `astropy.table.Table` or callable. + polarization : `~astropy.units.quantity.Quantity`, ``None``, `~astropy.table.QTable`, or callable. There are several different ways to set the polarization angle of the photons for a polarized source. In all cases, the angle is measured North through East. (We ignore the special case of a polarized source @@ -138,7 +137,7 @@ class Source(SimulationSequenceElement): equal length that contains the polarization angles as `~astropy.units.quantity.Quantity` object. - geomarea : `astropy.units.Quantity` or ``None``: + geomarea : `astropy.units.Quantity` or ``None`` Geometric opening area of telescope. If ``None`` then the flux must be given in photons per time, not per time per unit area.