diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9e0a57df..8ec86501 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,6 +14,11 @@ jobs: fail-fast: false matrix: include: + - os: ubuntu-latest + python-version: '3.11' + name: Python 3.11 with minimal dependencies + toxenv: py311-test + - os: ubuntu-latest python-version: '3.10' name: Python 3.10 with minimal dependencies @@ -60,9 +65,9 @@ jobs: toxenv: build_docs steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install testing dependencies @@ -71,6 +76,6 @@ jobs: run: tox -v -e ${{ matrix.toxenv }} - name: Upload coverage to codecov if: ${{ contains(matrix.toxenv,'-cov') }} - uses: codecov/codecov-action@v1.0.13 + uses: codecov/codecov-action@v3 with: file: ./coverage.xml \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4fe8c22b..5efff0bb 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,22 +10,18 @@ jobs: matrix: os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 name: Install Python with: - python-version: '3.9' - - name: Install cibuildwheel - run: | - python -m pip install --upgrade pip - python -m pip install cibuildwheel + python-version: '3.11' - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v2.16.2 env: - CIBW_BUILD: cp37-manylinux_x86_64 cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 + CIBW_BUILD: cp37-manylinux_x86_64 cp38-manylinux_x86_64 cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 CIBW_TEST_EXTRAS: test CIBW_TEST_COMMAND: pytest --pyargs turbustat - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: path: ./wheelhouse/*.whl @@ -37,22 +33,18 @@ jobs: matrix: os: [macos-latest] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 name: Install Python with: - python-version: '3.9' - - name: Install cibuildwheel - run: | - python -m pip install --upgrade pip - python -m pip install cibuildwheel + python-version: '3.11' - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v2.16.2 env: - CIBW_BUILD: cp37-* cp38-* cp39-* cp310-* + CIBW_BUILD: cp37-* cp38-* cp39-* cp310-* cp311-* CIBW_TEST_EXTRAS: test CIBW_TEST_COMMAND: pytest --pyargs turbustat - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: path: ./wheelhouse/*.whl @@ -64,22 +56,18 @@ jobs: matrix: os: [windows-latest] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 name: Install Python with: - python-version: '3.9' - - name: Install cibuildwheel - run: | - python -m pip install --upgrade pip - python -m pip install cibuildwheel + python-version: '3.11' - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v2.16.2 env: - CIBW_BUILD: cp37-* cp38-* cp39-* + CIBW_BUILD: cp39-*win_amd64 cp310-*win_amd64 cp311-*win_amd64 CIBW_TEST_EXTRAS: test CIBW_TEST_COMMAND: pytest --pyargs turbustat - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: path: ./wheelhouse/*.whl @@ -87,18 +75,18 @@ jobs: name: Build source distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 name: Install Python with: - python-version: '3.9' + python-version: '3.11' - name: Install build run: | python -m pip install --upgrade pip python -m pip install build - name: Build sdist run: python -m build --sdist --outdir dist/ . - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v3 with: path: dist/*.tar.gz @@ -108,11 +96,11 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') steps: - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v3 with: name: artifact path: dist - - uses: pypa/gh-action-pypi-publish@master + - uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index e41d8cc6..0d8f4d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ docs/api *.pyc *.DS_Store +*.ipynb_checkpoints turbustat/version.py turbustat/cython_version.py diff --git a/setup.cfg b/setup.cfg index 52cfa8f3..1586d47d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ testpaths = "turbustat" "docs" astropy_header = true doctest_plus = enabled text_file_format = rst -addopts = --doctest-rst +addopts = --doctest-rst -p no:warnings [coverage:run] omit = diff --git a/tox.ini b/tox.ini index 41baae71..2759c61a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{37,38,39,310}-test{,-all,-dev,-cov} + py{37,38,39,310,311}-test{,-all,-dev,-cov} build_docs codestyle requires = @@ -32,8 +32,8 @@ extras = all: all commands = pip freeze - !cov: pytest --open-files --pyargs turbustat {toxinidir}/docs {posargs} - cov: pytest --open-files --pyargs turbustat {toxinidir}/docs --cov turbustat --cov-config={toxinidir}/setup.cfg {posargs} + !cov: pytest --pyargs turbustat {toxinidir}/docs {posargs} + cov: pytest --pyargs turbustat {toxinidir}/docs --cov turbustat --cov-config={toxinidir}/setup.cfg {posargs} cov: coverage xml -o {toxinidir}/coverage.xml [testenv:build_docs] diff --git a/turbustat/moments/_moment_errs.py b/turbustat/moments/_moment_errs.py index fcffaeb2..679eb22f 100644 --- a/turbustat/moments/_moment_errs.py +++ b/turbustat/moments/_moment_errs.py @@ -238,7 +238,7 @@ def _slice0(cube, axis, scale): result = np.zeros(shp) * cube.unit ** 2 view = [slice(None)] * 3 - valid = np.zeros(shp, dtype=np.bool) + valid = np.zeros(shp, dtype=bool) for i in range(cube.shape[axis]): view[axis] = i diff --git a/turbustat/statistics/base_pspec2.py b/turbustat/statistics/base_pspec2.py index 2a2d3482..95eebc6c 100644 --- a/turbustat/statistics/base_pspec2.py +++ b/turbustat/statistics/base_pspec2.py @@ -88,7 +88,7 @@ def compute_radial_pspec(self, logspacing=False, max_bin=None, **kwargs): ''' # Check if azimuthal constraints are given - if kwargs.get("theta_0"): + if "theta_0" in kwargs: azim_constraint_flag = True else: azim_constraint_flag = False @@ -106,7 +106,8 @@ def compute_radial_pspec(self, logspacing=False, max_bin=None, **kwargs): # Attach units to freqs self._freqs = self.freqs / u.pix - def fit_pspec(self, brk=None, log_break=False, low_cut=None, + def fit_pspec(self, fit_unbinned=False, + brk=None, log_break=False, low_cut=None, high_cut=None, min_fits_pts=10, weighted_fit=False, bootstrap=False, bootstrap_kwargs={}, verbose=False): @@ -118,6 +119,10 @@ def fit_pspec(self, brk=None, log_break=False, low_cut=None, Parameters ---------- + fit_unbinned : bool, optional + Fits the unbinned 2D power-spectrum to the linear model. Default is True. + Use False to fit the binned 1D power-spectrum instead and replicate fitting + in earlier TurbuStat versions. brk : float or None, optional Guesses for the break points. If given as a list, the length of the list sets the number of break points to be fit. If a choice is @@ -150,29 +155,62 @@ def fit_pspec(self, brk=None, log_break=False, low_cut=None, self._bootstrap_flag = bootstrap - # Make the data to fit to - if low_cut is None: - # Default to the largest frequency, since this is just 1 pixel - # in the 2D PSpec. - self.low_cut = 1. / (0.5 * float(max(self.ps2D.shape)) * u.pix) - else: - self.low_cut = self._to_pixel_freq(low_cut) + if fit_unbinned: + yy_freq, xx_freq = make_radial_freq_arrays(self.ps2D.shape) + + freqs_2d = np.sqrt(yy_freq**2 + xx_freq**2) / u.pix + + # Make the data to fit to + if low_cut is None: + # Default to the largest frequency, since this is just 1 pixel + # in the 2D PSpec. + self.low_cut = 1. / (0.5 * float(max(self.ps2D.shape)) * u.pix) + else: + self.low_cut = self._to_pixel_freq(low_cut) + + if high_cut is None: + # self.high_cut = self.freqs.max().value / u.pix + self.high_cut = freqs_2d.value.max() / u.pix + else: + self.high_cut = self._to_pixel_freq(high_cut) + + + clip_mask = clip_func(freqs_2d.value, + self.low_cut.value, + self.high_cut.value) + + + x = np.log10(freqs_2d.value[clip_mask]) + y = np.log10(self.ps2D[clip_mask]) - if high_cut is None: - self.high_cut = self.freqs.max().value / u.pix else: - self.high_cut = self._to_pixel_freq(high_cut) + # Make the data to fit to + if low_cut is None: + # Default to the largest frequency, since this is just 1 pixel + # in the 2D PSpec. + self.low_cut = 1. / (0.5 * float(max(self.ps2D.shape)) * u.pix) + else: + self.low_cut = self._to_pixel_freq(low_cut) + + if high_cut is None: + self.high_cut = self.freqs.max().value / u.pix + else: + self.high_cut = self._to_pixel_freq(high_cut) - x = np.log10(self.freqs[clip_func(self.freqs.value, self.low_cut.value, - self.high_cut.value)].value) + x = np.log10(self.freqs[clip_func(self.freqs.value, self.low_cut.value, + self.high_cut.value)].value) - clipped_ps1D = self.ps1D[clip_func(self.freqs.value, - self.low_cut.value, - self.high_cut.value)] - y = np.log10(clipped_ps1D) + clipped_ps1D = self.ps1D[clip_func(self.freqs.value, + self.low_cut.value, + self.high_cut.value)] + y = np.log10(clipped_ps1D) if weighted_fit: + if fit_unbinned: + raise NotImplementedError("Error propagation for the unbinned modeling is not " + "implemented yet.") + # Currently this will run only for the binned fitting. clipped_stddev = self.ps1D_stddev[clip_func(self.freqs.value, self.low_cut.value, self.high_cut.value)] @@ -181,6 +219,10 @@ def fit_pspec(self, brk=None, log_break=False, low_cut=None, y_err = 0.434 * clipped_stddev / clipped_ps1D + weights = 1 / y_err**2 + else: + weights = None + if brk is not None: # Try the fit with a break in it. if not log_break: @@ -192,11 +234,6 @@ def fit_pspec(self, brk=None, log_break=False, low_cut=None, assert brk.unit == u.dimensionless_unscaled brk = brk.value - if weighted_fit: - weights = 1 / y_err**2 - else: - weights = None - brk_fit = Lm_Seg(x, y, brk, weights=weights) brk_fit.fit_model(verbose=verbose, cov_type='HC3') @@ -246,7 +283,7 @@ def fit_pspec(self, brk=None, log_break=False, low_cut=None, x = sm.add_constant(x) if weighted_fit: - model = sm.WLS(y, x, missing='drop', weights=1 / y_err**2) + model = sm.WLS(y, x, missing='drop', weights=weights) else: model = sm.OLS(y, x, missing='drop') @@ -614,8 +651,6 @@ def plot_fit(self, show_2D=False, show_residual=True, good_interval = clip_func(self.freqs.value, self.low_cut.value, self.high_cut.value) - y_fit = self.fit.fittedvalues - if show_residual: if isinstance(self.slope, np.ndarray): # Broken linear model @@ -679,7 +714,11 @@ def plot_fit(self, show_2D=False, show_residual=True, fmt=symbol, markersize=5, alpha=0.5, capsize=10, elinewidth=3) - ax_1D.plot(np.log10(xvals[fit_index]), y_fit, linestyle='-', + xvals_plot = np.log10(xvals[fit_index]).ravel() + # y_fit = self.fit.fittedvalues + y_fit = self.fit.predict(sm.add_constant(xvals_plot)) + + ax_1D.plot(xvals_plot, y_fit, linestyle='-', label=label, linewidth=3, color=fit_color) if show_residual: diff --git a/turbustat/statistics/elliptical_powerlaw.py b/turbustat/statistics/elliptical_powerlaw.py index fe72dc90..0779b031 100644 --- a/turbustat/statistics/elliptical_powerlaw.py +++ b/turbustat/statistics/elliptical_powerlaw.py @@ -5,6 +5,8 @@ from astropy.modeling import Fittable2DModel, Parameter, fitting from warnings import warn +log_ten = 2.302585092994046 + def fit_elliptical_powerlaw(values, x, y, p0, fit_method='LevMarq', bootstrap=False, niters=100, alpha=0.6827, @@ -99,7 +101,8 @@ def fit_elliptical_powerlaw(values, x, y, p0, fit_method='LevMarq', model.theta.fixed = True fitter = fitting.LevMarLSQFitter() - fit_model = fitter(model, x, y, values, weights=weights) + fit_model = fitter(model, x, y, values, weights=weights, + estimate_jacobian=True) resids = np.sum(np.abs(values - fit_model(x, y))) @@ -111,7 +114,8 @@ def fit_elliptical_powerlaw(values, x, y, p0, fit_method='LevMarq', model_f = LogEllipticalPowerLaw2D(*p0_f) fitter_f = fitting.LevMarLSQFitter() - fit_model_f = fitter(model_f, x, y, values, weights=weights) + fit_model_f = fitter(model_f, x, y, values, weights=weights, + estimate_jacobian=True) resids_f = np.sum(np.abs(values - fit_model_f(x, y))) @@ -225,10 +229,10 @@ class LogEllipticalPowerLaw2D(Fittable2DModel): Power-law index. """ - logamplitude = Parameter(default=0, bounds=(-10, None)) - ellip_transf = Parameter(default=1) - theta = Parameter(default=0) - gamma = Parameter(default=-1) + logamplitude = Parameter(default=0., bounds=(-10, None)) + ellip_transf = Parameter(default=1, bounds=(-50, 50)) + theta = Parameter(default=0, bounds=(-np.pi, np.pi)) + gamma = Parameter(default=-1, bounds=(-20., 20.)) @classmethod def evaluate(cls, x, y, logamplitude, ellip_transf, theta, gamma): @@ -255,6 +259,46 @@ def evaluate(cls, x, y, logamplitude, ellip_transf, theta, gamma): return model + # Attempt at numerical implementation for the gradient. + # Implementation is not currently stable. + + # def fit_deriv(self, x, y, logamplitude, ellip_transf, theta, gamma): + # """ + # Derivatives of the model with respect to parameters + # """ + + # ellip = 1.0 / (1 + np.exp(-ellip_transf)) + + # costhet = np.cos(theta) + # sinthet = np.sin(theta) + + # q = ellip + + # term1 = (q * costhet)**2 + sinthet**2 + # term2 = 2 * (1 - q**2) * sinthet * costhet + # term3 = (q * sinthet)**2 + costhet**2 + + # r2 = x**2 * term1 + x * y * term2 + y**2 * term3 + + # # print(ellip, costhet, sinthet) + # # print(r2) + + # d_logamplitude = 0.5 * gamma / (log_ten * r2) + + # d_ellip_transf = 0.5 * gamma * ellip * (1 - ellip) * (q**2 - 1) * (x**2 * costhet**2 - y**2 * sinthet**2) / r2 + + # d_theta = 0.5 * gamma * ellip * (1 - ellip) * (x**2 - y**2) * sinthet * costhet / r2 + + # d_gamma = 0.5 * np.log10(r2) + + # # print(d_logamplitude) + # # print(d_ellip_transf) + # # print(d_theta) + # # print(d_gamma) + + # return [d_logamplitude, d_ellip_transf, d_theta, d_gamma] + + def interval_transform(x, a, b): return np.log(x - a) - np.log(b - x) diff --git a/turbustat/statistics/mvc/mvc.py b/turbustat/statistics/mvc/mvc.py index 0f888f12..d284fef6 100644 --- a/turbustat/statistics/mvc/mvc.py +++ b/turbustat/statistics/mvc/mvc.py @@ -229,7 +229,8 @@ def run(self, verbose=False, beam_correct=False, use_pyfftw=False, threads=1, pyfftw_kwargs={}, radial_pspec_kwargs={}, low_cut=None, high_cut=None, - fit_2D=True, fit_kwargs={}, fit_2D_kwargs={}, + fit_kwargs={}, fit_unbinned=False, + fit_2D=True, fit_2D_kwargs={}, save_name=None, xunit=u.pix**-1, use_wavenumber=False): ''' Full computation of MVC. For fitting parameters and radial binning @@ -253,10 +254,12 @@ def run(self, verbose=False, beam_correct=False, Low frequency cut off in frequencies used in the fitting. high_cut : `~astropy.units.Quantity`, optional High frequency cut off in frequencies used in the fitting. - fit_2D : bool, optional - Fit an elliptical power-law model to the 2D power spectrum. fit_kwargs : dict, optional Passed to `~PowerSpectrum.fit_pspec`. + fit_unbinned : bool, optional + Passed to `~PowerSpectrum.fit_pspec`. Default is False. + fit_2D : bool, optional + Fit an elliptical power-law model to the 2D power spectrum. fit_2D_kwargs : dict, optional Keyword arguments for `~MVC.fit_2Dpspec`. Use the `low_cut` and `high_cut` keywords to provide fit limits. @@ -280,7 +283,9 @@ def run(self, verbose=False, beam_correct=False, **pyfftw_kwargs) self.compute_radial_pspec(**radial_pspec_kwargs) - self.fit_pspec(low_cut=low_cut, high_cut=high_cut, **fit_kwargs) + self.fit_pspec(low_cut=low_cut, high_cut=high_cut, + fit_unbinned=fit_unbinned, + **fit_kwargs) if fit_2D: self.fit_2Dpspec(low_cut=low_cut, high_cut=high_cut, diff --git a/turbustat/statistics/pspec_bispec/bispec.py b/turbustat/statistics/pspec_bispec/bispec.py index 990443bd..b02256ef 100644 --- a/turbustat/statistics/pspec_bispec/bispec.py +++ b/turbustat/statistics/pspec_bispec/bispec.py @@ -114,8 +114,8 @@ def compute_bispectrum(self, show_progress=True, use_pyfftw=False, bispec_shape = (int(self.shape[0] / 2.), int(self.shape[1] / 2.)) - self._bispectrum = np.zeros(bispec_shape, dtype=np.complex) - self._bicoherence = np.zeros(bispec_shape, dtype=np.float) + self._bispectrum = np.zeros(bispec_shape, dtype=complex) + self._bicoherence = np.zeros(bispec_shape, dtype=float) self._tracker = np.zeros(self.shape, dtype=np.int16) biconorm = np.ones_like(self.bispectrum, dtype=float) diff --git a/turbustat/statistics/pspec_bispec/pspec.py b/turbustat/statistics/pspec_bispec/pspec.py index 4cf96a42..bc040aa3 100644 --- a/turbustat/statistics/pspec_bispec/pspec.py +++ b/turbustat/statistics/pspec_bispec/pspec.py @@ -122,9 +122,9 @@ def run(self, verbose=False, beam_correct=False, apodize_kernel=None, alpha=0.2, beta=0.0, use_pyfftw=False, threads=1, pyfftw_kwargs={}, - low_cut=None, high_cut=None, - fit_2D=True, radial_pspec_kwargs={}, fit_kwargs={}, - fit_2D_kwargs={}, + low_cut=None, high_cut=None, radial_pspec_kwargs={}, + fit_kwargs={}, fit_unbinned=False, + fit_2D=True, fit_2D_kwargs={}, xunit=u.pix**-1, save_name=None, use_wavenumber=False): ''' @@ -157,12 +157,14 @@ def run(self, verbose=False, beam_correct=False, Low frequency cut off in frequencies used in the fitting. high_cut : `~astropy.units.Quantity`, optional High frequency cut off in frequencies used in the fitting. - fit_2D : bool, optional - Fit an elliptical power-law model to the 2D power spectrum. radial_pspec_kwargs : dict, optional Passed to `~PowerSpectrum.compute_radial_pspec`. fit_kwargs : dict, optional Passed to `~PowerSpectrum.fit_pspec`. + fit_unbinned : bool, optional + Passed to `~PowerSpectrum.fit_pspec`. Default is False. + fit_2D : bool, optional + Fit an elliptical power-law model to the 2D power spectrum. fit_2D_kwargs : dict, optional Keyword arguments for `PowerSpectrum.fit_2Dpspec`. Use the `low_cut` and `high_cut` keywords to provide fit limits. @@ -178,6 +180,10 @@ def run(self, verbose=False, beam_correct=False, if pyfftw_kwargs.get('threads') is not None: pyfftw_kwargs.pop('threads') + # Pop fit_unbinned if given as kwarg in dict. + if fit_kwargs.get('fit_unbinned') is not None: + fit_kwargs.pop('fit_unbinned') + self.compute_pspec(apodize_kernel=apodize_kernel, alpha=alpha, beta=beta, beam_correct=beam_correct, @@ -186,7 +192,9 @@ def run(self, verbose=False, beam_correct=False, self.compute_radial_pspec(**radial_pspec_kwargs) - self.fit_pspec(low_cut=low_cut, high_cut=high_cut, **fit_kwargs) + self.fit_pspec(fit_unbinned=fit_unbinned, + low_cut=low_cut, high_cut=high_cut, + **fit_kwargs) if fit_2D: self.fit_2Dpspec(low_cut=low_cut, high_cut=high_cut, diff --git a/turbustat/statistics/scf/scf.py b/turbustat/statistics/scf/scf.py index 18ecff47..fdb0cdee 100644 --- a/turbustat/statistics/scf/scf.py +++ b/turbustat/statistics/scf/scf.py @@ -258,12 +258,12 @@ def compute_spectrum(self, **kwargs): if self.scf_surface is None: self.compute_surface() - if kwargs.get("logspacing"): + if "logspacing" in kwargs: warn("Disabled log-spaced bins. This does not work well for the" " SCF.", TurbuStatMetricWarning) kwargs.pop('logspacing') - if kwargs.get("theta_0"): + if "theta_0" in kwargs: azim_constraint_flag = True else: azim_constraint_flag = False diff --git a/turbustat/statistics/vca_vcs/vca.py b/turbustat/statistics/vca_vcs/vca.py index 31b8e429..8baa9d88 100644 --- a/turbustat/statistics/vca_vcs/vca.py +++ b/turbustat/statistics/vca_vcs/vca.py @@ -129,7 +129,8 @@ def run(self, verbose=False, beam_correct=False, pyfftw_kwargs={}, radial_pspec_kwargs={}, low_cut=None, high_cut=None, - fit_2D=True, fit_kwargs={}, fit_2D_kwargs={}, + fit_kwargs={}, fit_unbinned=False, + fit_2D=True, fit_2D_kwargs={}, save_name=None, xunit=u.pix**-1, use_wavenumber=False): ''' Full computation of VCA. @@ -163,10 +164,12 @@ def run(self, verbose=False, beam_correct=False, Low frequency cut off in frequencies used in the fitting. high_cut : `~astropy.units.Quantity`, optional High frequency cut off in frequencies used in the fitting. - fit_2D : bool, optional - Fit an elliptical power-law model to the 2D power spectrum. fit_kwargs : dict, optional Passed to `~PowerSpectrum.fit_pspec`. + fit_unbinned : bool, optional + Passed to `~PowerSpectrum.fit_pspec`. Default is False. + fit_2D : bool, optional + Fit an elliptical power-law model to the 2D power spectrum. fit_2D_kwargs : dict, optional Keyword arguments for `~VCA.fit_2Dpspec`. Use the `low_cut` and `high_cut` keywords to provide fit limits. @@ -183,6 +186,10 @@ def run(self, verbose=False, beam_correct=False, if pyfftw_kwargs.get('threads') is not None: pyfftw_kwargs.pop('threads') + # Pop fit_unbinned if given as kwarg in dict. + if fit_kwargs.get('fit_unbinned') is not None: + fit_kwargs.pop('fit_unbinned') + self.compute_pspec(apodize_kernel=apodize_kernel, alpha=alpha, beta=beta, beam_correct=beam_correct, @@ -190,7 +197,8 @@ def run(self, verbose=False, beam_correct=False, **pyfftw_kwargs) self.compute_radial_pspec(**radial_pspec_kwargs) - self.fit_pspec(low_cut=low_cut, high_cut=high_cut, **fit_kwargs) + self.fit_pspec(low_cut=low_cut, high_cut=high_cut, fit_unbinned=fit_unbinned, + **fit_kwargs) if fit_2D: self.fit_2Dpspec(low_cut=low_cut, high_cut=high_cut, diff --git a/turbustat/statistics/wavelets/wavelet_transform.py b/turbustat/statistics/wavelets/wavelet_transform.py index 9efb3826..b518e702 100644 --- a/turbustat/statistics/wavelets/wavelet_transform.py +++ b/turbustat/statistics/wavelets/wavelet_transform.py @@ -152,7 +152,7 @@ def compute_transform(self, show_progress=True, scale_normalization=True, A = len(self.scales) if keep_convolved_arrays: - self._Wf = np.zeros((A, n0, m0), dtype=np.float) + self._Wf = np.zeros((A, n0, m0), dtype=float) else: self._Wf = None diff --git a/turbustat/tests/test_ellipplaw.py b/turbustat/tests/test_ellipplaw.py index ac16cdb6..ad1b501b 100644 --- a/turbustat/tests/test_ellipplaw.py +++ b/turbustat/tests/test_ellipplaw.py @@ -101,7 +101,7 @@ def test_simple_ellipplaw_2D_anisotropic(plaw, ellip, theta): npt.assert_allclose(theta, fit_theta, atol=0.01) -@pytest.mark.parametrize('plaw', [2, 3, 4]) +@pytest.mark.parametrize('plaw', [0.5, 1, 1.5, 2, 3, 4, 4.5, 5., 6]) def test_simple_ellipplaw_2D_isotropic(plaw): # Must have ellip = 1 for this test. Just be sure... @@ -176,3 +176,89 @@ def test_simple_ellipplaw_2D_isotropic(plaw): # And theta should not move assert test_stderr[2] == 0. + + +@pytest.mark.parametrize('plaw', [0.5, 1, 1.5, 2, 3, 4, 4.5, 5., 6]) +def test_direct_ellipplaw_2D_isotropic(plaw): + + from astropy.modeling import Fittable2DModel, Parameter, fitting + + # Must have ellip = 1 for this test. Just be sure... + # ellip = 1. + ellip = 0.99999 + # ellip = 0.5 + + # Theta doesn't matter, but we'll test for what happens when the + # elliptical parameters are left free. + theta = np.pi / 2. + + imsize = 256 + + # Generate a red noise model + psd = make_extended(imsize, powerlaw=plaw, ellip=ellip, theta=theta, + return_fft=True) + + psd = np.abs(psd)**2 + + # Initial guesses are based on the azimuthally-average spectrum, so it's + # valid to give it good initial guesses for the index + # Guess it is fairly elliptical. Tends not to be too sensitive to this. + ellip_transf = interval_transform(ellip, 0, 1.) + # We fit twice w/ thetas offset by pi / 2, so theta also should not be too + # sensitive. + + p0 = (3.7, + ellip_transf, + np.pi / 2., + - (2. + np.random.normal(scale=0.5))) + + yy, xx = np.mgrid[-imsize / 2:imsize / 2, -imsize / 2:imsize / 2] + + # Don't fit the 0, 0 point. It isn't defined by the model. + valids = psd != 0. + + assert np.isfinite(psd[valids]).all() + assert (psd[valids] > 0.).all() + + model = LogEllipticalPowerLaw2D(*p0) + + # TODO: These are leftover from testing the fit_deriv implementation + # there seems to be an instability in how the gradient is calculated. + # Return to this in the future. + + # p0_f = list(p0) + # p0_f[2] = (p0[2] + np.pi / 2.) % np.pi + # model = LogEllipticalPowerLaw2D(*p0_f) + + fitter = fitting.LevMarLSQFitter(calc_uncertainties=True) + # fitter = fitting.LevMarLSQFitter(calc_uncertainties=False) + + # fitter = fitting.TRFLSQFitter(calc_uncertainties=True) + + fit_model = fitter(model, + xx[valids], + yy[valids], + np.log10(psd[valids]), + weights=None, + estimate_jacobian=True) + # estimate_jacobian=False) + + # Do the parameters match? + + params = fit_model.parameters + + # print(f"Init params: {p0}") + # print(f"Fit params: {params}") + # print(fitter.fit_info) + + # Require the index to be within 0.1 of the actual, + # the ellipticity to be within 0.02, and the theta to be within ~3 deg + + npt.assert_allclose(-plaw, params[-1], rtol=0.01, atol=1e-2) + + npt.assert_allclose(1.0, inverse_interval_transform(params[1], 0, 1), + rtol=0.01, atol=1e-2) + + # Theta should be the original + assert theta == np.pi / 2. + diff --git a/turbustat/tests/test_pspec.py b/turbustat/tests/test_pspec.py index f2ed4fdc..b801c775 100644 --- a/turbustat/tests/test_pspec.py +++ b/turbustat/tests/test_pspec.py @@ -187,6 +187,35 @@ def test_pspec_weightfit(plaw): npt.assert_allclose(-plaw, test.slope, rtol=0.02) +@pytest.mark.parametrize('plaw', np.arange(0.5, 5, 0.67)) +def test_pspec_unbin_vs_bin(plaw): + ''' + The slopes with azimuthal constraints should be the same. When elliptical, + the power will be different along the different directions, but the slope + should remain the same. + ''' + + imsize = 64 + theta = 0 + + # Generate a red noise model + img = make_extended(imsize, powerlaw=plaw, ellip=1., theta=theta, + return_fft=False) + + test = PowerSpectrum(fits.PrimaryHDU(img)) + test.run(fit_unbinned=True, + fit_2D=False) + + # Ensure slopes are consistent to within 2% + npt.assert_allclose(-plaw, test.slope, rtol=0.02) + + test.run(fit_unbinned=False, + fit_2D=False) + + # Ensure slopes are consistent to within 2% + npt.assert_allclose(-plaw, test.slope, rtol=0.02) + + @pytest.mark.parametrize('theta', [0., np.pi / 4., np.pi / 2., 7 * np.pi / 8.]) def test_pspec_fit2D(theta): diff --git a/turbustat/tests/test_wavelet.py b/turbustat/tests/test_wavelet.py index 6b11c021..d5bc39da 100644 --- a/turbustat/tests/test_wavelet.py +++ b/turbustat/tests/test_wavelet.py @@ -67,7 +67,7 @@ def test_Wavelet_method_failbreak(): # No break and only 1 slope assert tester.brk is None - assert isinstance(tester.slope, np.float) + assert isinstance(tester.slope, float) def test_Wavelet_method_fitlimits():