pfb-model-spec 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pfb_model_spec/__init__.py +5 -0
- pfb_model_spec/_container_image.py +1 -0
- pfb_model_spec/cabs/__init__.py +27 -0
- pfb_model_spec/cabs/onboard.yml +10 -0
- pfb_model_spec/cli/__init__.py +23 -0
- pfb_model_spec/cli/onboard.py +68 -0
- pfb_model_spec/core/__init__.py +1 -0
- pfb_model_spec/core/onboard.py +181 -0
- pfb_model_spec/modelspec.py +361 -0
- pfb_model_spec-0.0.1.dist-info/METADATA +39 -0
- pfb_model_spec-0.0.1.dist-info/RECORD +14 -0
- pfb_model_spec-0.0.1.dist-info/WHEEL +4 -0
- pfb_model_spec-0.0.1.dist-info/entry_points.txt +3 -0
- pfb_model_spec-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CONTAINER_IMAGE = "ghcr.io/landmanbester/pfb-model-spec:0.0.1"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Cab definitions for generated Stimela cabs."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
CAB_DIR = Path(__file__).parent
|
|
6
|
+
AVAILABLE_CABS = [p.stem for p in CAB_DIR.glob("*.yml")]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_cab_path(name: str) -> Path:
|
|
10
|
+
"""Get path to a cab definition.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
name: Name of the cab (without .yml extension)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Path to the cab YAML file
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
FileNotFoundError: If the cab doesn't exist
|
|
20
|
+
"""
|
|
21
|
+
cab_path = CAB_DIR / f"{name}.yml"
|
|
22
|
+
if not cab_path.exists():
|
|
23
|
+
raise FileNotFoundError(f"Cab not found: {name}")
|
|
24
|
+
return cab_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["CAB_DIR", "AVAILABLE_CABS", "get_cab_path"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cabs:
|
|
2
|
+
onboard:
|
|
3
|
+
flavour: python
|
|
4
|
+
command: pfb_model_spec.core.onboard.onboard
|
|
5
|
+
name: onboard
|
|
6
|
+
info:
|
|
7
|
+
Print setup instructions for CI/CD, PyPI publishing, and GitHub configuration.
|
|
8
|
+
image: ghcr.io/landmanbester/pfb-model-spec:0.0.1
|
|
9
|
+
inputs: {}
|
|
10
|
+
outputs: {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""CLI for pfb-model-spec."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="pfbspec",
|
|
7
|
+
help="Model specification for pfb-imaging",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback()
|
|
13
|
+
def callback() -> None:
|
|
14
|
+
"""Model specification for pfb-imaging"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Register subcommands below. Imports go here (bottom) to avoid circular imports.
|
|
19
|
+
from pfb_model_spec.cli.onboard import onboard # noqa: E402
|
|
20
|
+
|
|
21
|
+
app.command(name="onboard")(onboard)
|
|
22
|
+
|
|
23
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Annotated, Literal
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from hip_cargo import StimelaMeta, stimela_cab
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@stimela_cab(
|
|
8
|
+
name="onboard",
|
|
9
|
+
info="Print setup instructions for CI/CD, PyPI publishing, and GitHub configuration.",
|
|
10
|
+
)
|
|
11
|
+
def onboard(
|
|
12
|
+
backend: Annotated[
|
|
13
|
+
Literal["auto", "native", "apptainer", "singularity", "docker", "podman"],
|
|
14
|
+
typer.Option(
|
|
15
|
+
help="Execution backend.",
|
|
16
|
+
),
|
|
17
|
+
StimelaMeta(
|
|
18
|
+
skip=True,
|
|
19
|
+
),
|
|
20
|
+
] = "auto",
|
|
21
|
+
always_pull_images: Annotated[
|
|
22
|
+
bool,
|
|
23
|
+
typer.Option(
|
|
24
|
+
help="Always pull container images, even if cached locally.",
|
|
25
|
+
),
|
|
26
|
+
StimelaMeta(
|
|
27
|
+
skip=True,
|
|
28
|
+
),
|
|
29
|
+
] = False,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Print setup instructions for CI/CD, PyPI publishing, and GitHub configuration.
|
|
33
|
+
"""
|
|
34
|
+
if backend == "native" or backend == "auto":
|
|
35
|
+
try:
|
|
36
|
+
# Pre-flight must_exist for remote URIs before dispatching.
|
|
37
|
+
from hip_cargo.utils.runner import preflight_remote_must_exist # noqa: E402
|
|
38
|
+
|
|
39
|
+
preflight_remote_must_exist(
|
|
40
|
+
onboard,
|
|
41
|
+
dict(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Lazy import the core implementation
|
|
45
|
+
from pfb_model_spec.core.onboard import onboard as onboard_core # noqa: E402
|
|
46
|
+
|
|
47
|
+
# Call the core function with all parameters
|
|
48
|
+
onboard_core()
|
|
49
|
+
return
|
|
50
|
+
except ImportError:
|
|
51
|
+
if backend == "native":
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
# Resolve container image from installed package metadata
|
|
55
|
+
from hip_cargo.utils.config import get_container_image # noqa: E402
|
|
56
|
+
from hip_cargo.utils.runner import run_in_container # noqa: E402
|
|
57
|
+
|
|
58
|
+
image = get_container_image("pfb-model-spec")
|
|
59
|
+
if image is None:
|
|
60
|
+
raise RuntimeError("No Container URL in pfb-model-spec metadata.")
|
|
61
|
+
|
|
62
|
+
run_in_container(
|
|
63
|
+
onboard,
|
|
64
|
+
dict(),
|
|
65
|
+
image=image,
|
|
66
|
+
backend=backend,
|
|
67
|
+
always_pull_images=always_pull_images,
|
|
68
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core implementations for pfb-model-spec."""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
def onboard():
|
|
2
|
+
"""Print setup instructions for CI/CD, PyPI publishing, and GitHub configuration."""
|
|
3
|
+
print(
|
|
4
|
+
"""
|
|
5
|
+
================================================================================
|
|
6
|
+
pfb-model-spec — Setup Instructions
|
|
7
|
+
================================================================================
|
|
8
|
+
|
|
9
|
+
Follow these steps to complete the CI/CD and publishing setup for your project.
|
|
10
|
+
|
|
11
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
Step 1: Create a GitHub Repository
|
|
13
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
First, install the GitHub CLI (gh) if you haven't already:
|
|
16
|
+
|
|
17
|
+
# macOS
|
|
18
|
+
brew install gh
|
|
19
|
+
|
|
20
|
+
# Ubuntu/Debian
|
|
21
|
+
sudo apt install gh
|
|
22
|
+
|
|
23
|
+
# Other platforms: https://github.com/cli/cli#installation
|
|
24
|
+
|
|
25
|
+
Then authenticate:
|
|
26
|
+
|
|
27
|
+
gh auth login
|
|
28
|
+
|
|
29
|
+
Push your project to GitHub:
|
|
30
|
+
|
|
31
|
+
gh repo create landmanbester/pfb-model-spec --public --source=. --push
|
|
32
|
+
|
|
33
|
+
Or create the repo manually at https://github.com/new and push:
|
|
34
|
+
|
|
35
|
+
git remote add origin git@github.com:landmanbester/pfb-model-spec.git
|
|
36
|
+
git push -u origin main
|
|
37
|
+
|
|
38
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
Step 2: Set Up Trusted Publishing on PyPI
|
|
40
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
Use PyPI's "pending trusted publisher" feature to pre-authorize GitHub
|
|
43
|
+
Actions to publish your package. The PyPI project will be created
|
|
44
|
+
automatically on the first successful publish.
|
|
45
|
+
|
|
46
|
+
Go to: https://pypi.org/manage/account/publishing/
|
|
47
|
+
|
|
48
|
+
Scroll down to "Add a new pending publisher" and fill in:
|
|
49
|
+
PyPI project name: pfb-model-spec
|
|
50
|
+
Owner: landmanbester
|
|
51
|
+
Repository: pfb-model-spec
|
|
52
|
+
Workflow: publish.yml
|
|
53
|
+
Environment: pypi
|
|
54
|
+
|
|
55
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
Step 3: Create GitHub Environment
|
|
57
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
The publish workflow requires a GitHub environment named "pypi".
|
|
60
|
+
|
|
61
|
+
Go to: https://github.com/landmanbester/pfb-model-spec/settings/environments
|
|
62
|
+
|
|
63
|
+
Click "New environment" and name it: pypi
|
|
64
|
+
|
|
65
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
Step 4: Create a GitHub App (for Automated Cab Updates)
|
|
67
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
The update-cabs workflow needs to push commits to the repository. A GitHub
|
|
70
|
+
App is required so these commits can bypass branch protection rules.
|
|
71
|
+
|
|
72
|
+
a) Create the app:
|
|
73
|
+
|
|
74
|
+
Go to: https://github.com/settings/apps → "New GitHub App"
|
|
75
|
+
|
|
76
|
+
Settings:
|
|
77
|
+
Name: pfb-model-spec-bot (or any name you like)
|
|
78
|
+
Homepage: https://github.com/landmanbester/pfb-model-spec
|
|
79
|
+
Webhook: Uncheck "Active" (not needed)
|
|
80
|
+
Permissions: Repository → Contents → Read & write
|
|
81
|
+
Where: Only on this account
|
|
82
|
+
|
|
83
|
+
b) Generate a private key:
|
|
84
|
+
|
|
85
|
+
On the app page → "Generate a private key"
|
|
86
|
+
Save the downloaded .pem file.
|
|
87
|
+
|
|
88
|
+
c) Install the app on your repository:
|
|
89
|
+
|
|
90
|
+
On the app page → "Install App" → Select your repository.
|
|
91
|
+
|
|
92
|
+
d) Add secrets to your repository:
|
|
93
|
+
|
|
94
|
+
Go to: https://github.com/landmanbester/pfb-model-spec/settings/secrets/actions
|
|
95
|
+
|
|
96
|
+
Add two secrets:
|
|
97
|
+
APP_CLIENT_ID → The Client ID shown on the app's settings page
|
|
98
|
+
APP_PRIVATE_KEY → The contents of the .pem file you downloaded
|
|
99
|
+
|
|
100
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
Step 5: Set Up Branch Protection
|
|
102
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
Protect your default branch so all changes go through CI.
|
|
105
|
+
|
|
106
|
+
Go to: https://github.com/landmanbester/pfb-model-spec/settings/rules
|
|
107
|
+
|
|
108
|
+
Click "New ruleset" and configure:
|
|
109
|
+
|
|
110
|
+
Name: Protect main
|
|
111
|
+
Enforcement: Active
|
|
112
|
+
Target: Default branch
|
|
113
|
+
Rules to enable:
|
|
114
|
+
- Require a pull request before merging
|
|
115
|
+
- Require status checks to pass (add: "Code Quality", "Tests")
|
|
116
|
+
- Block force pushes
|
|
117
|
+
|
|
118
|
+
Bypass list:
|
|
119
|
+
- Add your GitHub App (created in Step 4) so it can push
|
|
120
|
+
automated cab updates
|
|
121
|
+
|
|
122
|
+
Make sure to click "Create" to save the ruleset.
|
|
123
|
+
|
|
124
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
Step 6: Make Your First Release
|
|
126
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
When you're ready to publish to PyPI:
|
|
129
|
+
|
|
130
|
+
uv run tbump 0.0.0
|
|
131
|
+
|
|
132
|
+
This will:
|
|
133
|
+
1. Update version in pyproject.toml and __init__.py
|
|
134
|
+
2. Regenerate cab definitions with the release version
|
|
135
|
+
3. Commit, tag, and push
|
|
136
|
+
4. GitHub Actions will build and publish to PyPI and ghcr.io
|
|
137
|
+
|
|
138
|
+
================================================================================
|
|
139
|
+
That's it! Your CI/CD pipeline is fully configured.
|
|
140
|
+
================================================================================
|
|
141
|
+
|
|
142
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
Day-to-Day Development: Image Tag Workflow
|
|
144
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
The container image is stored in src/pfb_model_spec/_container_image.py
|
|
147
|
+
as the single source of truth for cab generation and container fallback
|
|
148
|
+
execution. The tag portion must stay in sync with your current context.
|
|
149
|
+
|
|
150
|
+
When you create a feature branch:
|
|
151
|
+
|
|
152
|
+
1. Edit src/pfb_model_spec/_container_image.py and change the tag:
|
|
153
|
+
|
|
154
|
+
CONTAINER_IMAGE = "ghcr.io/landmanbester/pfb-model-spec:my-feature"
|
|
155
|
+
|
|
156
|
+
2. Commit and develop as normal — pre-commit hooks will generate cab
|
|
157
|
+
definitions with the correct branch-specific image tag.
|
|
158
|
+
|
|
159
|
+
You do NOT need to reset the tag before merging. On merge to main,
|
|
160
|
+
the update-cabs workflow automatically:
|
|
161
|
+
|
|
162
|
+
- Resets the CONTAINER_IMAGE tag to "latest"
|
|
163
|
+
- Regenerates cab definitions
|
|
164
|
+
- Commits _container_image.py and cab YAML files
|
|
165
|
+
|
|
166
|
+
During releases, tbump updates the tag to the semantic version
|
|
167
|
+
(e.g. 0.1.0) via its before-commit hooks.
|
|
168
|
+
|
|
169
|
+
────────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
NOTE: Once you've completed the setup steps above, you can safely delete the
|
|
172
|
+
onboard command (cli/onboard.py and core/onboard.py) and remove it from
|
|
173
|
+
cli/__init__.py.
|
|
174
|
+
|
|
175
|
+
Add your own commands following the same pattern — define a CLI function
|
|
176
|
+
with type hints and a @stimela_cab decorator, and Stimela cab definitions
|
|
177
|
+
will be auto-generated from your CLI definitions via pre-commit hooks.
|
|
178
|
+
|
|
179
|
+
For more details, see: https://github.com/landmanbester/hip-cargo#readme
|
|
180
|
+
"""
|
|
181
|
+
)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import sympy as sm
|
|
3
|
+
import xarray as xr
|
|
4
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
5
|
+
from sympy.parsing.sympy_parser import parse_expr
|
|
6
|
+
from sympy.utilities.lambdify import lambdify
|
|
7
|
+
|
|
8
|
+
specs = ["genesis", "exodus"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fit_image_cube(time, freq, image, wgt=None, nbasist=None, nbasisf=None, method="poly", sigmasq=0):
|
|
12
|
+
"""
|
|
13
|
+
Fit the time and frequency axes of an image cube where
|
|
14
|
+
|
|
15
|
+
time - (ntime) time axis
|
|
16
|
+
freq - (nband) frequency axis
|
|
17
|
+
image - (ntime, nband, nx, ny) pixelated image
|
|
18
|
+
wgt - (ntime, nband) optional per time and frequency weights
|
|
19
|
+
nbasist - number of time basis functions
|
|
20
|
+
nbasisf - number of frequency basis functions
|
|
21
|
+
method - method to use for fitting (poly or Legendre)
|
|
22
|
+
sigmasq - optional regularisation term to add to the Hessian
|
|
23
|
+
to improve conditioning
|
|
24
|
+
|
|
25
|
+
method:
|
|
26
|
+
poly - fit a monomials in time and frequency
|
|
27
|
+
Legendre - fit a Legendre polynomial in time and frequency
|
|
28
|
+
|
|
29
|
+
returns:
|
|
30
|
+
coeffs - fitted coefficients
|
|
31
|
+
x_index, y_index - pixel locations of non-zero pixels in the image
|
|
32
|
+
expr - a string representing the symbolic expression describing the fit
|
|
33
|
+
params - tuple of str, parameters to pass into function (excluding t and f)
|
|
34
|
+
tfunc - function which scales the time domain appropriately for method
|
|
35
|
+
ffunc - function which scales the frequency domain appropriately for method
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
The fit is performed in scaled coordinates (t=time/ref_time,f=freq/ref_freq)
|
|
39
|
+
"""
|
|
40
|
+
ntime = time.size
|
|
41
|
+
nband = freq.size
|
|
42
|
+
ref_time = time[0]
|
|
43
|
+
ref_freq = freq[0]
|
|
44
|
+
import sympy as sm
|
|
45
|
+
from sympy.abc import a, f, t
|
|
46
|
+
|
|
47
|
+
if nbasist is None:
|
|
48
|
+
nbasist = ntime
|
|
49
|
+
else:
|
|
50
|
+
assert nbasist <= ntime
|
|
51
|
+
if nbasisf is None:
|
|
52
|
+
nbasisf = nband
|
|
53
|
+
else:
|
|
54
|
+
assert nbasisf <= nband
|
|
55
|
+
|
|
56
|
+
mask = np.any(image, axis=(0, 1)) # over t and f axes
|
|
57
|
+
x_index, y_index = np.where(mask)
|
|
58
|
+
ncomps = x_index.size
|
|
59
|
+
|
|
60
|
+
# components excluding zeros
|
|
61
|
+
beta = image[:, :, x_index, y_index].reshape(ntime * nband, ncomps)
|
|
62
|
+
if wgt is not None:
|
|
63
|
+
wgt = wgt.reshape(ntime * nband, 1)
|
|
64
|
+
else:
|
|
65
|
+
wgt = np.ones((ntime * nband, 1), dtype=float)
|
|
66
|
+
|
|
67
|
+
# nothing to fit
|
|
68
|
+
if ntime == 1 and nband == 1:
|
|
69
|
+
coeffs = beta
|
|
70
|
+
expr = a
|
|
71
|
+
params = (a,)
|
|
72
|
+
elif method == "poly":
|
|
73
|
+
wt = time / ref_time
|
|
74
|
+
tfunc = t / ref_time
|
|
75
|
+
xfit = np.tile(wt[:, None], (nband, nbasist)) ** np.arange(nbasist)
|
|
76
|
+
params = sm.symbols(f"t(0:{nbasist})")
|
|
77
|
+
expr = sum(co * t**i for i, co in enumerate(params))
|
|
78
|
+
# the costant offset will always be included since nbasist is at least one
|
|
79
|
+
if nband > 1:
|
|
80
|
+
wf = freq / ref_freq
|
|
81
|
+
ffunc = f / ref_freq
|
|
82
|
+
xf = np.tile(wf[:, None], (ntime, nbasisf - 1)) ** np.arange(1, nbasisf)
|
|
83
|
+
xfit = np.hstack((xfit, xf))
|
|
84
|
+
paramsf = sm.symbols(f"f(1:{nbasisf})")
|
|
85
|
+
expr += sum(co * f ** (i + 1) for i, co in enumerate(paramsf))
|
|
86
|
+
params += paramsf
|
|
87
|
+
|
|
88
|
+
elif method == "Legendre":
|
|
89
|
+
# scale to lie between -1,1 for stability
|
|
90
|
+
if ntime > 1:
|
|
91
|
+
tmax = time.max()
|
|
92
|
+
tmin = time.min()
|
|
93
|
+
wt = time - (tmax + tmin) / 2
|
|
94
|
+
wtmax = wt.max()
|
|
95
|
+
wt /= wtmax
|
|
96
|
+
# function to convert time to interp domain
|
|
97
|
+
tfunc = (t - (tmax + tmin) / 2) / wtmax
|
|
98
|
+
else:
|
|
99
|
+
wt = time
|
|
100
|
+
tfunc = t
|
|
101
|
+
xt = np.zeros((ntime, nbasist), dtype=float)
|
|
102
|
+
params = sm.symbols(f"t(0:{nbasist})")
|
|
103
|
+
if nbasist > 1:
|
|
104
|
+
expr = 0
|
|
105
|
+
for i in range(nbasist):
|
|
106
|
+
vals = np.polynomial.Legendre.basis(i)(wt)
|
|
107
|
+
xt[:, i] = vals
|
|
108
|
+
expr += sm.polys.orthopolys.legendre_poly(i, t) * params[i]
|
|
109
|
+
else:
|
|
110
|
+
xt[...] = 1.0
|
|
111
|
+
expr = params[0]
|
|
112
|
+
xfit = np.tile(xt, (nband, 1))
|
|
113
|
+
paramsf = sm.symbols(f"f(1:{nbasisf})")
|
|
114
|
+
if nband > 1:
|
|
115
|
+
xf = np.zeros((nband, nbasisf - 1))
|
|
116
|
+
fmax = freq.max()
|
|
117
|
+
fmin = freq.min()
|
|
118
|
+
wf = freq - (fmax + fmin) / 2
|
|
119
|
+
wfmax = wf.max()
|
|
120
|
+
wf /= wfmax
|
|
121
|
+
ffunc = (f - (fmax + fmin) / 2) / wfmax
|
|
122
|
+
for i in range(1, nbasisf):
|
|
123
|
+
vals = np.polynomial.Legendre.basis(i)(wf)
|
|
124
|
+
xf[:, i - 1] = vals
|
|
125
|
+
expr += sm.polys.orthopolys.legendre_poly(i, f) * paramsf[i - 1]
|
|
126
|
+
xf = np.tile(xf, (ntime, 1))
|
|
127
|
+
xfit = np.hstack((xfit, xf))
|
|
128
|
+
params += paramsf
|
|
129
|
+
else:
|
|
130
|
+
raise NotImplementedError(f"Method {method} not implemented")
|
|
131
|
+
|
|
132
|
+
dirty_coeffs = xfit.T.dot(wgt * beta)
|
|
133
|
+
hess_coeffs = xfit.T.dot(wgt * xfit)
|
|
134
|
+
# to improve conditioning
|
|
135
|
+
if sigmasq:
|
|
136
|
+
hess_coeffs += sigmasq * np.eye(hess_coeffs.shape[0])
|
|
137
|
+
coeffs = np.linalg.solve(hess_coeffs, dirty_coeffs)
|
|
138
|
+
|
|
139
|
+
return coeffs, x_index, y_index, str(expr), list(map(str, params)), str(tfunc), str(ffunc)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fit_image_fscube(freq, image, wgt=None, nbasisf=None, method="Legendre", sigmasq=0):
|
|
143
|
+
"""
|
|
144
|
+
Fit the frequency axis of an image cube where
|
|
145
|
+
|
|
146
|
+
freq - (nband,) frequency axis
|
|
147
|
+
image - (nband, ncorr, nx, ny) pixelated image
|
|
148
|
+
wgt - (nband, ncorr) optional per time and frequency weights
|
|
149
|
+
nbasisf - number of frequency basis functions
|
|
150
|
+
method - method to use for fitting (poly or Legendre)
|
|
151
|
+
sigmasq - optional regularisation term to add to the Hessian
|
|
152
|
+
to improve conditioning
|
|
153
|
+
|
|
154
|
+
method:
|
|
155
|
+
poly - fit a monomials to frequency axis
|
|
156
|
+
Legendre - fit a Legendre polynomial to frequency
|
|
157
|
+
|
|
158
|
+
returns:
|
|
159
|
+
coeffs - (ncorr, nbasisf, ncomps) fitted coefficients
|
|
160
|
+
x_index, y_index - (ncomps,) pixel locations of non-zero pixels in the image
|
|
161
|
+
expr - a string representing the symbolic expression describing the fit
|
|
162
|
+
params - tuple of str, parameters to pass into function (excluding t and f)
|
|
163
|
+
ffunc - function which scales the frequency domain appropriately for method
|
|
164
|
+
"""
|
|
165
|
+
nband = freq.size
|
|
166
|
+
ref_freq = freq[0]
|
|
167
|
+
import sympy as sm
|
|
168
|
+
from sympy.abc import f
|
|
169
|
+
|
|
170
|
+
if nbasisf is None:
|
|
171
|
+
nbasisf = nband
|
|
172
|
+
else:
|
|
173
|
+
assert nbasisf <= nband
|
|
174
|
+
|
|
175
|
+
nband, ncorr, nx, ny = image.shape
|
|
176
|
+
mask = np.any(image, axis=(0, 1)) # over freq and corr axes
|
|
177
|
+
x_index, y_index = np.where(mask)
|
|
178
|
+
ncomps = x_index.size
|
|
179
|
+
|
|
180
|
+
# components excluding zeros
|
|
181
|
+
beta = image[:, :, x_index, y_index].reshape(nband, ncorr, ncomps)
|
|
182
|
+
if wgt is not None:
|
|
183
|
+
wgt = wgt.reshape(nband, ncorr, 1)
|
|
184
|
+
else:
|
|
185
|
+
wgt = np.ones((nband, ncorr, 1), dtype=float)
|
|
186
|
+
|
|
187
|
+
params = sm.symbols(f"f(0:{nbasisf})")
|
|
188
|
+
if nband == 1: # nothing to fit
|
|
189
|
+
coeffs = beta
|
|
190
|
+
expr = f
|
|
191
|
+
params = (f,)
|
|
192
|
+
elif method == "poly":
|
|
193
|
+
wf = freq / ref_freq
|
|
194
|
+
ffunc = f / ref_freq
|
|
195
|
+
xf = np.tile(wf[:, None], (1, nbasisf)) ** np.arange(nbasisf)
|
|
196
|
+
expr = sum(co * f**i for i, co in enumerate(params))
|
|
197
|
+
|
|
198
|
+
elif method == "Legendre":
|
|
199
|
+
xf = np.zeros((nband, nbasisf), dtype=float)
|
|
200
|
+
fmax = freq.max()
|
|
201
|
+
fmin = freq.min()
|
|
202
|
+
wf = freq - (fmax + fmin) / 2
|
|
203
|
+
wfmax = wf.max()
|
|
204
|
+
wf /= wfmax
|
|
205
|
+
ffunc = (f - (fmax + fmin) / 2) / wfmax
|
|
206
|
+
xf[:, 0] = 1.0
|
|
207
|
+
expr = params[0]
|
|
208
|
+
for i in range(1, nbasisf):
|
|
209
|
+
vals = np.polynomial.Legendre.basis(i)(wf)
|
|
210
|
+
xf[:, i] = vals
|
|
211
|
+
expr += sm.polys.orthopolys.legendre_poly(i, f) * params[i]
|
|
212
|
+
else:
|
|
213
|
+
raise NotImplementedError(f"Method {method} not implemented")
|
|
214
|
+
|
|
215
|
+
# fit each correlation separately
|
|
216
|
+
coeffs = np.zeros((ncorr, nbasisf, ncomps), dtype=beta.dtype)
|
|
217
|
+
for c in range(ncorr):
|
|
218
|
+
dirty_coeffs = xf.T.dot(wgt[:, c] * beta[:, c])
|
|
219
|
+
hess_coeffs = xf.T.dot(wgt[:, c] * xf)
|
|
220
|
+
# to improve conditioning
|
|
221
|
+
if sigmasq:
|
|
222
|
+
hess_coeffs += sigmasq * np.eye(hess_coeffs.shape[0])
|
|
223
|
+
coeffs[c] = np.linalg.solve(hess_coeffs, dirty_coeffs)
|
|
224
|
+
|
|
225
|
+
return coeffs, x_index, y_index, str(expr), list(map(str, params)), str(ffunc)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def eval_coeffs_to_cube(time, freq, nx, ny, coeffs, x_index, y_index, expr, paramf, texpr, fexpr):
|
|
229
|
+
ntime = time.size
|
|
230
|
+
nfreq = freq.size
|
|
231
|
+
|
|
232
|
+
image = np.zeros((ntime, nfreq, nx, ny), dtype=float)
|
|
233
|
+
params = sm.symbols(("t", "f"))
|
|
234
|
+
params += sm.symbols(tuple(paramf))
|
|
235
|
+
symexpr = parse_expr(expr)
|
|
236
|
+
modelf = lambdify(params, symexpr)
|
|
237
|
+
texpr = parse_expr(texpr)
|
|
238
|
+
tfunc = lambdify(params[0], texpr)
|
|
239
|
+
fexpr = parse_expr(fexpr)
|
|
240
|
+
ffunc = lambdify(params[1], fexpr)
|
|
241
|
+
for i, tval in enumerate(time):
|
|
242
|
+
for j, fval in enumerate(freq):
|
|
243
|
+
image[i, j, x_index, y_index] = modelf(tfunc(tval), ffunc(fval), *coeffs)
|
|
244
|
+
|
|
245
|
+
return image
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def eval_coeffs_to_slice(
|
|
249
|
+
time,
|
|
250
|
+
freq,
|
|
251
|
+
coeffs,
|
|
252
|
+
x_index,
|
|
253
|
+
y_index,
|
|
254
|
+
expr,
|
|
255
|
+
paramf,
|
|
256
|
+
texpr,
|
|
257
|
+
fexpr,
|
|
258
|
+
nxi,
|
|
259
|
+
nyi,
|
|
260
|
+
cellxi,
|
|
261
|
+
cellyi,
|
|
262
|
+
x0i,
|
|
263
|
+
y0i,
|
|
264
|
+
nxo,
|
|
265
|
+
nyo,
|
|
266
|
+
cellxo,
|
|
267
|
+
cellyo,
|
|
268
|
+
x0o,
|
|
269
|
+
y0o,
|
|
270
|
+
):
|
|
271
|
+
image_in = np.zeros((nxi, nyi), dtype=float)
|
|
272
|
+
params = sm.symbols(("t", "f"))
|
|
273
|
+
params += sm.symbols(tuple(paramf))
|
|
274
|
+
symexpr = parse_expr(expr)
|
|
275
|
+
modelf = lambdify(params, symexpr)
|
|
276
|
+
texpr = parse_expr(texpr)
|
|
277
|
+
tfunc = lambdify(params[0], texpr)
|
|
278
|
+
fexpr = parse_expr(fexpr)
|
|
279
|
+
ffunc = lambdify(params[1], fexpr)
|
|
280
|
+
image_in[x_index, y_index] = modelf(tfunc(time), ffunc(freq), *coeffs)
|
|
281
|
+
|
|
282
|
+
pix_area_in = cellxi * cellyi
|
|
283
|
+
pix_area_out = cellxo * cellyo
|
|
284
|
+
area_ratio = pix_area_out / pix_area_in
|
|
285
|
+
|
|
286
|
+
xin = (-(nxi // 2) + np.arange(nxi)) * cellxi + x0i
|
|
287
|
+
yin = (-(nyi // 2) + np.arange(nyi)) * cellyi + y0i
|
|
288
|
+
xo = (-(nxo // 2) + np.arange(nxo)) * cellxo + x0o
|
|
289
|
+
yo = (-(nyo // 2) + np.arange(nyo)) * cellyo + y0o
|
|
290
|
+
|
|
291
|
+
# how many pixels to pad by to extrapolate with zeros
|
|
292
|
+
xldiff = xin.min() - xo.min()
|
|
293
|
+
if xldiff > 0.0:
|
|
294
|
+
npadxl = int(np.ceil(xldiff / cellxi))
|
|
295
|
+
else:
|
|
296
|
+
npadxl = 0
|
|
297
|
+
yldiff = yin.min() - yo.min()
|
|
298
|
+
if yldiff > 0.0:
|
|
299
|
+
npadyl = int(np.ceil(yldiff / cellyi))
|
|
300
|
+
else:
|
|
301
|
+
npadyl = 0
|
|
302
|
+
|
|
303
|
+
xudiff = xo.max() - xin.max()
|
|
304
|
+
if xudiff > 0.0:
|
|
305
|
+
npadxu = int(np.ceil(xudiff / cellxi))
|
|
306
|
+
else:
|
|
307
|
+
npadxu = 0
|
|
308
|
+
yudiff = yo.max() - yin.max()
|
|
309
|
+
if yudiff > 0.0:
|
|
310
|
+
npadyu = int(np.ceil(yudiff / cellyi))
|
|
311
|
+
else:
|
|
312
|
+
npadyu = 0
|
|
313
|
+
|
|
314
|
+
do_pad = npadxl > 0
|
|
315
|
+
do_pad |= npadxu > 0
|
|
316
|
+
do_pad |= npadyl > 0
|
|
317
|
+
do_pad |= npadyu > 0
|
|
318
|
+
if do_pad:
|
|
319
|
+
image_in = np.pad(image_in, ((npadxl, npadxu), (npadyl, npadyu)), mode="constant")
|
|
320
|
+
|
|
321
|
+
xin = (-(nxi // 2 + npadxl) + np.arange(nxi + npadxl + npadxu)) * cellxi + x0i
|
|
322
|
+
nxi = nxi + npadxl + npadxu
|
|
323
|
+
yin = (-(nyi // 2 + npadyl) + np.arange(nyi + npadyl + npadyu)) * cellyi + y0i
|
|
324
|
+
nyi = nyi + npadyl + npadyu
|
|
325
|
+
|
|
326
|
+
do_interp = cellxi != cellxo
|
|
327
|
+
do_interp |= cellyi != cellyo
|
|
328
|
+
do_interp |= x0i != x0o
|
|
329
|
+
do_interp |= y0i != y0o
|
|
330
|
+
do_interp |= nxi != nxo
|
|
331
|
+
do_interp |= nyi != nyo
|
|
332
|
+
if do_interp:
|
|
333
|
+
interpo = RegularGridInterpolator((xin, yin), image_in, bounds_error=True, method="linear")
|
|
334
|
+
xx, yy = np.meshgrid(xo, yo, indexing="ij")
|
|
335
|
+
return interpo((xx, yy)) * area_ratio
|
|
336
|
+
else:
|
|
337
|
+
return image_in
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def model_from_mds(mds_name, freqs=None):
|
|
341
|
+
"""
|
|
342
|
+
Evaluate component model at the original resolution
|
|
343
|
+
"""
|
|
344
|
+
mds = xr.open_zarr(mds_name, chunks=None)
|
|
345
|
+
if freqs is None:
|
|
346
|
+
freqs = mds.freqs.values
|
|
347
|
+
else:
|
|
348
|
+
freqs = np.atleast_1d(freqs)
|
|
349
|
+
return eval_coeffs_to_cube(
|
|
350
|
+
mds.times.values,
|
|
351
|
+
freqs,
|
|
352
|
+
mds.npix_x,
|
|
353
|
+
mds.npix_y,
|
|
354
|
+
mds.coefficients.values,
|
|
355
|
+
mds.location_x.values,
|
|
356
|
+
mds.location_y.values,
|
|
357
|
+
mds.parametrisation,
|
|
358
|
+
mds.params.values,
|
|
359
|
+
mds.texpr,
|
|
360
|
+
mds.fexpr,
|
|
361
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pfb-model-spec
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Model specification for pfb-imaging
|
|
5
|
+
Author: landmanbester
|
|
6
|
+
Author-email: landmanbester <lbester@sarao.ac.za>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: hip-cargo>=0.2.0
|
|
14
|
+
Requires-Dist: numpy ; extra == 'full'
|
|
15
|
+
Requires-Dist: scipy ; extra == 'full'
|
|
16
|
+
Requires-Dist: sympy ; extra == 'full'
|
|
17
|
+
Requires-Dist: xarray ; extra == 'full'
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Project-URL: Homepage, https://github.com/landmanbester/pfb-model-spec
|
|
20
|
+
Project-URL: Repository, https://github.com/landmanbester/pfb-model-spec
|
|
21
|
+
Project-URL: Bug Tracker, https://github.com/landmanbester/pfb-model-spec/issues
|
|
22
|
+
Provides-Extra: full
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pfb-model-spec
|
|
26
|
+
|
|
27
|
+
Model specification for pfb-imaging
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pfb-model-spec
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pfbspec --help
|
|
39
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pfb_model_spec/__init__.py,sha256=Z3wV_fwvYzgcGLu0NbF18YXJgq-fLbwidVT_UpFbAow,92
|
|
2
|
+
pfb_model_spec/_container_image.py,sha256=tpecVjNH_3lboB2nF-jQsrDdg0_GBiZSMfmtgSbNF_A,63
|
|
3
|
+
pfb_model_spec/cabs/__init__.py,sha256=7Jp91XKl-3QwF7sujSEetCay6gGwmv9EKFop6hVPpgw,634
|
|
4
|
+
pfb_model_spec/cabs/onboard.yml,sha256=1QjyKeabkUpUvoeKrhzQvy8pW1eFhd7apyXQmSlXKvQ,284
|
|
5
|
+
pfb_model_spec/cli/__init__.py,sha256=diaqF_mcnR69cdZHaQRhIGuCrYKQkv1vV6KgW65pfLo,459
|
|
6
|
+
pfb_model_spec/cli/onboard.py,sha256=lnFPZGoCUmo457I8NqZf2Saol2DFZJENHHOSv8NXKYY,1987
|
|
7
|
+
pfb_model_spec/core/__init__.py,sha256=lbBafo78iXfwsB4ndDWSh3VqDGw5dZArqqhXAe1MI2A,47
|
|
8
|
+
pfb_model_spec/core/onboard.py,sha256=YK-HrYLrvg53YwMxkmkR7cVCsGw4Fu54lnoEo90Z2d0,9064
|
|
9
|
+
pfb_model_spec/modelspec.py,sha256=v7xwUbKselmrn4YF_J_CxKGmwYamHEZ1wbkV_gS90lA,11796
|
|
10
|
+
pfb_model_spec-0.0.1.dist-info/licenses/LICENSE,sha256=F-fIq4GmaOnJVwQL8rn7V2e1onkioYJzOciwvUQct8A,1070
|
|
11
|
+
pfb_model_spec-0.0.1.dist-info/WHEEL,sha256=V5-3dKee3Zs8C4JP6swr6zdqriLsOpItBEQxe6_oWpY,81
|
|
12
|
+
pfb_model_spec-0.0.1.dist-info/entry_points.txt,sha256=IJkT-MkLtukIS1LIMvqr4UfK8VIVkArGBOCJJstahY0,52
|
|
13
|
+
pfb_model_spec-0.0.1.dist-info/METADATA,sha256=kPnSNqfnqbzHOUJsBCpANC57MTpaDyclYPG9vus1dis,1071
|
|
14
|
+
pfb_model_spec-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 landmanbester
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|