Skip to content

Commit 2c92503

Browse files
Enforce waveform minimum frequency location in v0.7 (#107)
* Initial plan * Add minimum frequency field to Waveform and update validation Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Update config templates to use waveform minimum frequency Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Move validation before pipeline init and improve error handling Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Fix meta_defaults access in validation code Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Fix type definition and add waveform variable to templates - Change minimum_frequency type to dict[str, float] only (remove float option) - Add waveform variable assignment to bilby.ini and lalinference.ini templates - Use waveform shorthand in lalinference.ini for consistency Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Add robust error handling for minimum frequency validation - Add type checking in bayeswave.py flow property - Add type checking in pesummary.py for both SubjectAnalysis and single analysis paths - Add type checking in pipeline.py PESummaryPipeline - Ensure all code paths validate that minimum frequency is a non-empty dict before calling .values() Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Split external blueprint tests to separate CI job - Create local copies of external blueprints with correct v0.7 waveform structure in tests/test_data/blueprints/ - Refactor test_specific_events.py to use local blueprints (must pass) with __file__-based absolute paths to be robust against cwd pollution from other tests - Add tests/external_blueprint_compat.py for external URL tests (not auto-discovered by unittest discover since file doesn't match test*.py pattern) - Add test-external-blueprints CI job to python-app.yml with continue-on-error: true (runs on single Python version only) - Fix bayeswave.py: use setdefault() for quality dict to avoid KeyError when quality section doesn't exist in meta - Fix analysis.py: calculate maximum frequency even when quality section is absent from event blueprint Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Update changelog and fix all tests to use local blueprints - Add 'Unreleased' section to CHANGELOG.rst documenting the breaking change for minimum frequency location with migration guide and PR reference - Update tests/blueprints.py helper module with absolute paths to local blueprint files - Create tests/test_data/blueprints/production-pe-priors.yaml (local copy) - Create tests/test_data/blueprints/gwtc-2-1/GW150914_095045.yaml (v0.7 compliant) - Update test_dag.py, test_cli_manage.py, test_yaml.py, test_review.py, and tests/test_pipelines/test_bilby.py to use local blueprints via the blueprints module instead of external git.ligo.org URLs Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> * Fix test_build_submit_dryruns: add copy frames and osg to bayeswave defaults The local production-pe.yaml was missing copy frames: True and osg: True in the bayeswave scheduler section. These settings are present in the external production-pe.yaml and are needed for the --copy-frames and --transfer-files flags to appear in the BayesWave build_dag dryrun output, which test_build_submit_dryruns asserts are present. Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: transientlunatic <4365778+transientlunatic@users.noreply.github.com>
1 parent aa8cff7 commit 2c92503

37 files changed

+868
-93
lines changed

.github/workflows/python-app.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,39 @@ jobs:
4343
run: |
4444
python -m unittest discover tests/
4545
46+
test-external-blueprints:
47+
# This job checks compatibility with the external asimov-data blueprints.
48+
# It is allowed to fail because external data files may lag behind code changes.
49+
runs-on: ubuntu-latest
50+
continue-on-error: true
51+
strategy:
52+
fail-fast: false
53+
matrix:
54+
python-version: ["3.11"]
55+
56+
steps:
57+
- uses: actions/checkout@v2
58+
with:
59+
fetch-depth: 0
60+
- name: Set up Python ${{ matrix.python-version }}
61+
uses: actions/setup-python@v2
62+
with:
63+
python-version: ${{ matrix.python-version }}
64+
- name: Install dependencies
65+
run: |
66+
python -m pip install --upgrade pip
67+
pip install .
68+
pip install .[bilby]
69+
- name: Set up git
70+
run: |
71+
git config --global init.defaultBranch master
72+
git config --global user.email "you@example.com"
73+
git config --global user.name "Your Name"
74+
75+
- name: Run external blueprint compatibility tests
76+
run: |
77+
python -m unittest tests.external_blueprint_compat -v
78+
4679
publish:
4780
name: Build and publish to PyPI
4881
needs: build-ubuntu-python

CHANGELOG.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
Unreleased
2+
==========
3+
4+
Breaking Changes
5+
----------------
6+
7+
**Waveform Minimum Frequency Location**
8+
The ``minimum frequency`` parameter **must** now be placed in the ``waveform``
9+
section of a blueprint. In earlier versions, placing it in the ``quality`` or
10+
``likelihood`` sections was permitted with a deprecation warning. As of v0.7,
11+
any blueprint that specifies ``minimum frequency`` under ``quality`` or
12+
``likelihood`` will be **rejected** with a ``ValueError`` and the blueprint
13+
will not be applied.
14+
15+
To migrate an existing blueprint, move the value from the old location::
16+
17+
# Old (no longer valid in v0.7)
18+
quality:
19+
minimum frequency:
20+
H1: 20
21+
L1: 20
22+
23+
to the new location::
24+
25+
# Correct (v0.7+)
26+
waveform:
27+
minimum frequency:
28+
H1: 20
29+
L1: 20
30+
31+
The error message will indicate which section the value was found in and
32+
prompt the user to update their blueprint accordingly.
33+
34+
GitHub Pull Requests
35+
--------------------
36+
37+
+ `github#107 <https://github.com/etive-io/asimov/pull/107>`_: Enforce waveform minimum frequency location (breaking change for old blueprints)
38+
139
0.7.0-alpha2
240
============
341

asimov/analysis.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,6 +1673,37 @@ def __init__(self, subject, name, pipeline, **kwargs):
16731673
"""
16741674

16751675
self.category = config.get("general", "calibration_directory")
1676+
1677+
# Early validation: Check for minimum frequency in wrong locations (v0.7)
1678+
# We need to check both the subject (event) metadata and the kwargs
1679+
# First, build the effective metadata as it will be in super().__init__
1680+
temp_meta = deepcopy(Analysis.meta_defaults)
1681+
1682+
# Add pipeline defaults if available
1683+
if hasattr(subject, 'ledger') and subject.ledger and "pipelines" in subject.ledger.data:
1684+
if pipeline in subject.ledger.data["pipelines"]:
1685+
temp_meta = update(temp_meta, deepcopy(subject.ledger.data["pipelines"][pipeline]))
1686+
1687+
# Add subject defaults
1688+
temp_meta = update(temp_meta, deepcopy(subject.meta))
1689+
1690+
# Add kwargs
1691+
temp_meta = update(temp_meta, deepcopy(kwargs))
1692+
1693+
# Now validate
1694+
if "quality" in temp_meta and "minimum frequency" in temp_meta["quality"]:
1695+
raise ValueError(
1696+
"Minimum frequency must be specified in the 'waveform' section, "
1697+
"not in the 'quality' section. Please update your blueprint to move "
1698+
"'minimum frequency' from 'quality' to 'waveform'."
1699+
)
1700+
if "likelihood" in temp_meta and "minimum frequency" in temp_meta["likelihood"]:
1701+
raise ValueError(
1702+
"Minimum frequency must be specified in the 'waveform' section, "
1703+
"not in the 'likelihood' section. Please update your blueprint to move "
1704+
"'minimum frequency' from 'likelihood' to 'waveform'."
1705+
)
1706+
16761707
super().__init__(subject, name, pipeline, **kwargs)
16771708
self._checks()
16781709

@@ -1701,11 +1732,9 @@ def __init__(self, subject, name, pipeline, **kwargs):
17011732
self.meta["sampler"]["lmax"] = self.meta["lmax"]
17021733

17031734
# Check that the upper frequency is included, otherwise calculate it
1704-
if "quality" in self.meta:
1705-
if ("maximum frequency" not in self.meta["quality"]) and (
1706-
"sample rate" in self.meta["likelihood"]
1707-
):
1708-
self.meta["quality"]["maximum frequency"] = {}
1735+
if "sample rate" in self.meta["likelihood"] and "interferometers" in self.meta:
1736+
if "maximum frequency" not in self.meta.get("quality", {}):
1737+
self.meta.setdefault("quality", {})["maximum frequency"] = {}
17091738
# Account for the PSD roll-off with the 0.875 factor
17101739
for ifo in self.meta["interferometers"]:
17111740
self.meta["quality"]["maximum frequency"][ifo] = int(

asimov/blueprints.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ class Waveform(Blueprint):
8989
description="The mode array to use in the waveform model.",
9090
default=None,
9191
)
92+
minimum_frequency: dict[str, float] | None = pydantic.Field(
93+
alias="minimum frequency",
94+
description="The minimum frequency for the waveform model, given as a dictionary of values per interferometer.",
95+
default=None,
96+
)
9297

9398

9499
model_config = ConfigDict(extra='forbid')

asimov/configs/bilby.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
{%- assign priors = production.meta['priors'] -%}
1616
{%- assign data = production.meta['data'] -%}
1717
{%- assign quality = production.meta['quality'] -%}
18+
{%- assign waveform = production.meta['waveform'] -%}
1819
{%- assign ifos = production.meta['interferometers'] -%}
1920

2021
{%- if data contains "calibration" %}
@@ -79,7 +80,7 @@ psd-length={{ likelihood['psd length'] | round }}
7980
psd-maximum-duration=1024
8081
psd-method=median
8182
psd-start-time=None
82-
minimum-frequency={ {% for ifo in ifos %}{{ifo}}:{{quality['minimum frequency'][ifo]}},{% endfor %}{% if likelihood contains 'start frequency'%} waveform: {{ likelihood['start frequency'] }} {% endif %} }
83+
minimum-frequency={ {% for ifo in ifos %}{{ifo}}:{{waveform['minimum frequency'][ifo]}},{% endfor %}{% if likelihood contains 'start frequency'%} waveform: {{ likelihood['start frequency'] }} {% endif %} }
8384
maximum-frequency={ {% for ifo in ifos %}{{ifo}}:{{quality['maximum frequency'][ifo]}},{% endfor %} }
8485
zero-noise=False
8586
tukey-roll-off={{ likelihood['roll off time'] | default: 0.4 }}

asimov/configs/lalinference.ini

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
{%- assign priors = prior_interface.convert() -%}
1212
{%- assign data = production.meta['data'] -%}
1313
{%- assign quality = production.meta['quality'] -%}
14+
{%- assign waveform = production.meta['waveform'] -%}
1415
{%- assign ifos = production.meta['interferometers'] -%}
1516
{%- if production.event.repository -%}
1617
{%- assign repo_dir = production.event.repository.directory -%}
@@ -81,12 +82,12 @@ types = {'H1': '{{ production.meta['data']['frame types']['H1'] }}', 'L1': '{{ p
8182
channels = {'H1': '{{ production.meta['data']['channels']['H1'] }}', 'L1': '{{ production.meta['data']['channels']['L1'] }}', 'V1': '{{ production.meta['data']['channels']['V1'] }}'}
8283

8384
[lalinference]
84-
flow = { {% if production.meta['interferometers'] contains "H1" %}'H1': {{ production.quality['minimum frequency']['H1'] }},{% endif %} {% if production.meta['interferometers'] contains "L1" %}'L1': {{ production.quality['minimum frequency']['L1']}},{% endif %} {% if production.meta['interferometers'] contains "V1" %} 'V1': {{ production.quality['minimum frequency']['V1']}} {% endif %} }
85+
flow = { {% if production.meta['interferometers'] contains "H1" %}'H1': {{ waveform['minimum frequency']['H1'] }},{% endif %} {% if production.meta['interferometers'] contains "L1" %}'L1': {{ waveform['minimum frequency']['L1']}},{% endif %} {% if production.meta['interferometers'] contains "V1" %} 'V1': {{ waveform['minimum frequency']['V1']}} {% endif %} }
8586
fhigh = { {% if production.meta['interferometers'] contains "H1" %}'H1': {{ production.meta['quality']['high frequency'] }},{% endif %} {% if production.meta['interferometers'] contains "L1" %}'L1': {{ production.meta['quality']['high frequency'] }},{% endif %} {% if production.meta['interferometers'] contains "V1" %} 'V1': {{ production.meta['quality']['high frequency'] }} {% endif %} }
8687

8788
[engine]
8889

89-
fref={{ production.meta['waveform']['reference frequency'] }}
90+
fref={{ waveform['reference frequency'] }}
9091

9192
approx = {{ production.meta['waveform']['approximant'] }}
9293
amporder = {{ prior_interface.get_amp_order() }}

asimov/configs/rift.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ types = { {% for ifo in ifos %}"{{ifo}}":"{{data['frame types'][ifo]}}",{% endfo
6262
channels = { {% for ifo in ifos %}"{{ifo}}":"{{data['channels'][ifo]}}",{% endfor %} }
6363

6464
[lalinference]
65-
flow = { {% for ifo in ifos %}"{{ifo}}":{{quality['minimum frequency'][ifo]}},{% endfor %} }
65+
flow = { {% for ifo in ifos %}"{{ifo}}":{{waveform['minimum frequency'][ifo]}},{% endfor %} }
6666
fhigh = { {% for ifo in ifos %}"{{ifo}}":{{quality['maximum frequency'][ifo]}},{% endfor %} }
6767

6868
[engine]

asimov/pipeline.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,15 @@ def submit_dag(self, dryrun=False):
522522
configfile = self.production.event.repository.find_prods(
523523
self.production.name, self.category
524524
)[0]
525+
526+
# Validate minimum frequency format
527+
min_freq = self.production.meta["waveform"]["minimum frequency"]
528+
if not isinstance(min_freq, dict) or not min_freq:
529+
raise ValueError(
530+
"Minimum frequency in 'waveform' section must be a non-empty dictionary "
531+
"mapping interferometer names to frequency values."
532+
)
533+
525534
command = [
526535
"--webdir",
527536
os.path.join(
@@ -537,7 +546,7 @@ def submit_dag(self, dryrun=False):
537546
"--approximant",
538547
self.production.meta["waveform"]["approximant"],
539548
"--f_low",
540-
str(min(self.production.meta["quality"]["minimum frequency"].values())),
549+
str(min(min_freq.values())),
541550
"--f_ref",
542551
str(self.production.meta["waveform"]["reference frequency"]),
543552
]

asimov/pipelines/bayeswave.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __init__(self, production, category=None):
5151
self.logger.info("Assuming analyses directory.")
5252

5353
if not production.meta.get("quality", {}).get("lowest minimum frequency", None):
54-
production.meta["quality"]["lowest minimum frequency"] = self.flow
54+
production.meta.setdefault("quality", {})["lowest minimum frequency"] = self.flow
5555

5656
def build_dag(self, user=None, dryrun=False):
5757
"""
@@ -288,7 +288,20 @@ def flow(self):
288288
minimum frequency from the list of interferometer
289289
lower frequencies.
290290
"""
291-
return min(self.production.meta["quality"]["minimum frequency"].values())
291+
if "waveform" not in self.production.meta or "minimum frequency" not in self.production.meta["waveform"]:
292+
raise ValueError(
293+
"Minimum frequency must be specified in the 'waveform' section. "
294+
"Please update your blueprint to include 'minimum frequency' in 'waveform'."
295+
)
296+
297+
min_freq = self.production.meta["waveform"]["minimum frequency"]
298+
if not isinstance(min_freq, dict) or not min_freq:
299+
raise ValueError(
300+
"Minimum frequency in 'waveform' section must be a non-empty dictionary "
301+
"mapping interferometer names to frequency values."
302+
)
303+
304+
return min(min_freq.values())
292305

293306
def before_submit(self):
294307
"""

asimov/pipelines/pesummary.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,15 @@ def submit_dag(self, dryrun=False):
268268
if "reference frequency" in dep_analysis.meta["waveform"]:
269269
f_refs.append(str(dep_analysis.meta["waveform"]["reference frequency"]))
270270

271-
if "quality" in dep_analysis.meta:
272-
if "minimum frequency" in dep_analysis.meta["quality"]:
273-
f_lows.append(str(min(dep_analysis.meta["quality"]["minimum frequency"].values())))
271+
if "waveform" in dep_analysis.meta:
272+
if "minimum frequency" in dep_analysis.meta["waveform"]:
273+
min_freq = dep_analysis.meta["waveform"]["minimum frequency"]
274+
if isinstance(min_freq, dict) and min_freq:
275+
f_lows.append(str(min(min_freq.values())))
276+
else:
277+
self.logger.warning(
278+
f"Invalid minimum frequency format in {dep_analysis.name}, skipping"
279+
)
274280

275281
# Config file should be added for each analysis that has samples
276282
if dep_config:
@@ -359,11 +365,18 @@ def submit_dag(self, dryrun=False):
359365
# If we have per-analysis f_low values, use them
360366
command += ["--f_low"]
361367
command.extend(f_lows)
362-
elif "minimum frequency" in quality_meta:
363-
command += [
364-
"--f_low",
365-
str(min(quality_meta["minimum frequency"].values())),
366-
]
368+
elif "minimum frequency" in waveform_meta:
369+
min_freq = waveform_meta["minimum frequency"]
370+
if isinstance(min_freq, dict) and min_freq:
371+
command += [
372+
"--f_low",
373+
str(min(min_freq.values())),
374+
]
375+
else:
376+
raise ValueError(
377+
"Minimum frequency in 'waveform' section must be a non-empty dictionary "
378+
"mapping interferometer names to frequency values."
379+
)
367380

368381
# f_ref - use per-analysis values if available, otherwise use global
369382
if is_subject_analysis and f_refs:

0 commit comments

Comments
 (0)