From 2aebc5b3c2a2efb57ceaa336af0d15f93e282db8 Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:04:03 -0600 Subject: [PATCH 1/8] test: add failing test for negative phase input (PHOEBE-style) --- tests/test_eclipsing_binary_binner.py | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_eclipsing_binary_binner.py b/tests/test_eclipsing_binary_binner.py index 1a470df..c56d49b 100644 --- a/tests/test_eclipsing_binary_binner.py +++ b/tests/test_eclipsing_binary_binner.py @@ -527,3 +527,57 @@ def test_both_eclipses_near_boundary(both_near_boundary_light_curve): bin_centers, bin_means, bin_errors = binner.bin_light_curve(plot=False) assert abs(len(bin_centers) - 100) <= 2 # Allow ±2 bins due to duplicates='drop' assert np.all(bin_errors > 0) + + +@pytest.fixture +def negative_phase_light_curve(): + """ + Fixture for light curve with phases in [-0.5, 0.5] range (PHOEBE-style). + Primary eclipse at phase 0, secondary eclipse wrapped around ±0.5 boundary. + """ + np.random.seed(99) + # Phases from -0.5 to 0.5 (PHOEBE convention) + phases = np.linspace(-0.5, 0.4999, 10000) + fluxes = np.ones_like(phases) + + # Primary eclipse centered at phase 0 + # Indices 4500-5500 correspond to phases around 0 + fluxes[4500:5000] = np.linspace(0.95, 0.8, 500) + fluxes[5000:5500] = np.linspace(0.8, 0.95, 500) + + # Secondary eclipse wrapping around ±0.5 boundary + # Near phase 0.5 (end of array) and -0.5 (start of array) + fluxes[9700:10000] = np.linspace(0.95, 0.9, 300) # phase ~0.47 to 0.5 + fluxes[0:300] = np.linspace(0.9, 0.95, 300) # phase -0.5 to ~-0.47 + + flux_errors = np.random.normal(0.01, 0.001, 10000) + + # Random subset + random_indices = np.random.choice(range(len(phases)), size=5000, replace=False) + return phases[random_indices], fluxes[random_indices], flux_errors[random_indices] + + +def test_negative_phase_input(negative_phase_light_curve): + """Test that negative phase inputs (PHOEBE-style [-0.5, 0.5]) are handled correctly.""" + phases, fluxes, flux_errors = negative_phase_light_curve + + # Verify input has negative phases + assert np.min(phases) < 0, "Test fixture should have negative phases" + + # Should not raise an error + binner = EclipsingBinaryBinner( + phases, fluxes, flux_errors, nbins=100, fraction_in_eclipse=0.2 + ) + + # Binning should succeed + bin_centers, bin_means, bin_errors = binner.bin_light_curve(plot=False) + + # Results should be in original phase space [-0.5, 0.5] + assert np.min(bin_centers) >= -0.5, "Bin centers should be >= -0.5" + assert np.max(bin_centers) <= 0.5, "Bin centers should be <= 0.5" + + # Should have expected number of bins (allow small tolerance) + assert abs(len(bin_centers) - 100) <= 2 + + # All bin errors should be positive + assert np.all(bin_errors > 0) From 897b875a832d5fc635e67307811119e6e235177a Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:33:56 -0600 Subject: [PATCH 2/8] feat: add phase normalization infrastructure for negative phases --- eclipsebin/binning.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/eclipsebin/binning.py b/eclipsebin/binning.py index 1f91556..16b1f4a 100644 --- a/eclipsebin/binning.py +++ b/eclipsebin/binning.py @@ -75,6 +75,22 @@ def __init__( "atol_secondary": None, } + # Detect and store original phase range for denormalization later + self._original_phase_min = np.min(phases) + self._original_phase_max = np.max(phases) + self._original_phase_range = self._original_phase_max - self._original_phase_min + + # Normalize phases to [0, 1] if not already + if self._original_phase_min < 0 or self._original_phase_max > 1: + self._needs_denormalization = True + normalized_phases = self._normalize_phases(phases) + sort_idx = np.argsort(normalized_phases) + self.data["phases"] = normalized_phases[sort_idx] + self.data["fluxes"] = fluxes[sort_idx] + self.data["flux_errors"] = flux_errors[sort_idx] + else: + self._needs_denormalization = False + self.set_atol(primary=atol_primary, secondary=atol_secondary) # Identify primary and secondary eclipse minima (in original phase space) @@ -95,6 +111,19 @@ def __init__( self.primary_eclipse = self.get_eclipse_boundaries(primary=True) self.secondary_eclipse = self.get_eclipse_boundaries(primary=False) + def _normalize_phases(self, phases): + """ + Normalize phases from original range to [0, 1]. + + Args: + phases (np.ndarray): Phases in original range + + Returns: + np.ndarray: Phases normalized to [0, 1] + """ + # Shift so minimum is at 0, then scale to [0, 1] + return (phases - self._original_phase_min) / self._original_phase_range + def find_minimum_flux_phase(self): """ Finds the phase of the minimum flux, corresponding to the primary eclipse. From 0ad006be782829e84fa8528ccbaeb663c6215c24 Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:36:18 -0600 Subject: [PATCH 3/8] feat: add phase denormalization method --- eclipsebin/binning.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/eclipsebin/binning.py b/eclipsebin/binning.py index 16b1f4a..4a8ddd1 100644 --- a/eclipsebin/binning.py +++ b/eclipsebin/binning.py @@ -124,6 +124,21 @@ def _normalize_phases(self, phases): # Shift so minimum is at 0, then scale to [0, 1] return (phases - self._original_phase_min) / self._original_phase_range + def _denormalize_phases(self, phases): + """ + Convert phases from [0, 1] back to original range. + + Args: + phases (np.ndarray): Phases in [0, 1] range + + Returns: + np.ndarray: Phases in original range + """ + if not self._needs_denormalization: + return phases + # Scale from [0, 1] back to original range + return phases * self._original_phase_range + self._original_phase_min + def find_minimum_flux_phase(self): """ Finds the phase of the minimum flux, corresponding to the primary eclipse. From 94998c7b4ea5f64bc0f740edfbdbcb02f8b1ae0b Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:38:09 -0600 Subject: [PATCH 4/8] feat: update rewrap method to denormalize to original phase range --- eclipsebin/binning.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/eclipsebin/binning.py b/eclipsebin/binning.py index 4a8ddd1..befc749 100644 --- a/eclipsebin/binning.py +++ b/eclipsebin/binning.py @@ -470,17 +470,20 @@ def find_bin_edges(self): def _rewrap_to_original_phase(self, phases_array): """ - Rewrap phases back to original phase space before unwrapping. + Rewrap phases back to original phase space before unwrapping, + then denormalize if original input had non-standard range. Args: - phases_array (np.ndarray): Array of phases in unwrapped space + phases_array (np.ndarray): Array of phases in unwrapped [0, 1] space Returns: np.ndarray: Phases shifted back to original space """ - if self._phase_shift == 0.0: - return phases_array - return (phases_array - self._phase_shift) % 1.0 + result = phases_array + if self._phase_shift != 0.0: + result = (result - self._phase_shift) % 1.0 + # Denormalize back to original range (e.g., [-0.5, 0.5]) + return self._denormalize_phases(result) def calculate_bins(self, return_in_original_phase=True): """ From bb18708364e958840fec158dc0afdcc4192f4813 Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:45:27 -0600 Subject: [PATCH 5/8] feat: update plot xlim for negative phase ranges --- eclipsebin/binning.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/eclipsebin/binning.py b/eclipsebin/binning.py index befc749..252cbbf 100644 --- a/eclipsebin/binning.py +++ b/eclipsebin/binning.py @@ -667,7 +667,10 @@ def plot_binned_light_curve(self, bin_centers, bin_means, bin_stds): ) plt.xlabel("Phases", fontsize=14) plt.ylabel("Normalized Flux", fontsize=14) - plt.xlim(0, 1) + if self._needs_denormalization: + plt.xlim(self._original_phase_min, self._original_phase_max) + else: + plt.xlim(0, 1) ylims = plt.ylim() # Get eclipse boundaries in original phase space @@ -738,7 +741,10 @@ def plot_unbinned_light_curve(self): label="Secondary Eclipse", ) plt.ylim(ylims) - plt.xlim(0, 1) + if self._needs_denormalization: + plt.xlim(self._original_phase_min, self._original_phase_max) + else: + plt.xlim(0, 1) plt.ylabel("Normalized Flux", fontsize=14) plt.xlabel("Phases", fontsize=14) plt.legend() From 661c92407cabd0e61533816d24e60c09988c0b13 Mon Sep 17 00:00:00 2001 From: Jackie Date: Sat, 3 Jan 2026 13:49:59 -0600 Subject: [PATCH 6/8] test: add comprehensive tests for negative phase edge cases --- tests/test_eclipsing_binary_binner.py | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_eclipsing_binary_binner.py b/tests/test_eclipsing_binary_binner.py index c56d49b..283f67f 100644 --- a/tests/test_eclipsing_binary_binner.py +++ b/tests/test_eclipsing_binary_binner.py @@ -581,3 +581,53 @@ def test_negative_phase_input(negative_phase_light_curve): # All bin errors should be positive assert np.all(bin_errors > 0) + + +def test_negative_phase_primary_at_zero(negative_phase_light_curve): + """Test that primary eclipse detection works when primary is at phase 0.""" + phases, fluxes, flux_errors = negative_phase_light_curve + + binner = EclipsingBinaryBinner( + phases, fluxes, flux_errors, nbins=100, fraction_in_eclipse=0.2 + ) + + # Primary minimum should be near phase 0 in original space + # In normalized space it's at 0.5, but we want to verify detection worked + primary_min_original = binner._denormalize_phases( + np.array([binner.primary_eclipse_min_phase]) + )[0] + assert -0.1 < primary_min_original < 0.1, ( + f"Primary eclipse should be near phase 0, got {primary_min_original}" + ) + + +def test_negative_phase_secondary_wrapped(): + """Test secondary eclipse that wraps around ±0.5 boundary in PHOEBE-style phases.""" + np.random.seed(101) + phases = np.linspace(-0.5, 0.4999, 10000) + fluxes = np.ones_like(phases) + + # Primary at phase 0 + fluxes[4800:5200] = np.linspace(0.95, 0.8, 400) + + # Secondary wrapping around ±0.5 (the boundary in PHOEBE space) + fluxes[9800:10000] = np.linspace(0.97, 0.92, 200) # near +0.5 + fluxes[0:200] = np.linspace(0.92, 0.97, 200) # near -0.5 + + flux_errors = np.abs(np.random.normal(0.01, 0.001, 10000)) + + random_indices = np.random.choice(range(len(phases)), size=5000, replace=False) + phases = phases[random_indices] + fluxes = fluxes[random_indices] + flux_errors = flux_errors[random_indices] + + binner = EclipsingBinaryBinner( + phases, fluxes, flux_errors, nbins=100, fraction_in_eclipse=0.2 + ) + + bin_centers, bin_means, bin_errors = binner.bin_light_curve(plot=False) + + # Verify output is in original range + assert np.min(bin_centers) >= -0.5 + assert np.max(bin_centers) <= 0.5 + assert np.all(bin_errors > 0) \ No newline at end of file From 5db6933582b555c05794cf83b3b817c22e415bd3 Mon Sep 17 00:00:00 2001 From: Jackie Date: Mon, 5 Jan 2026 09:07:47 -0700 Subject: [PATCH 7/8] chore: bump version to 0.4.0 for PyPI release --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index da097fd..ce78fff 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="eclipsebin", - version="0.3.3", + version="0.4.0", author="Jackie Blaum", author_email="jackie.blaum@gmail.com", description="A specialized binning scheme for eclipsing binary star light curves", From ffbe7e95d3a80e0e1575fc3b1496c2ae63a55914 Mon Sep 17 00:00:00 2001 From: Jackie Date: Mon, 5 Jan 2026 09:13:27 -0700 Subject: [PATCH 8/8] style: format code with black --- tests/test_eclipsing_binary_binner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_eclipsing_binary_binner.py b/tests/test_eclipsing_binary_binner.py index 283f67f..7e6f923 100644 --- a/tests/test_eclipsing_binary_binner.py +++ b/tests/test_eclipsing_binary_binner.py @@ -596,9 +596,9 @@ def test_negative_phase_primary_at_zero(negative_phase_light_curve): primary_min_original = binner._denormalize_phases( np.array([binner.primary_eclipse_min_phase]) )[0] - assert -0.1 < primary_min_original < 0.1, ( - f"Primary eclipse should be near phase 0, got {primary_min_original}" - ) + assert ( + -0.1 < primary_min_original < 0.1 + ), f"Primary eclipse should be near phase 0, got {primary_min_original}" def test_negative_phase_secondary_wrapped(): @@ -630,4 +630,4 @@ def test_negative_phase_secondary_wrapped(): # Verify output is in original range assert np.min(bin_centers) >= -0.5 assert np.max(bin_centers) <= 0.5 - assert np.all(bin_errors > 0) \ No newline at end of file + assert np.all(bin_errors > 0)