Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/version_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ jobs:
if [ "${{ github.event_name }}" = "push" ] && [ "$VERSION" != "$TAG" ]; then
echo "::error::Version in pyproject.toml ($VERSION) does not match tag ($TAG)"; exit 1
fi
grep -q "## \[$VERSION\]" CHANGELOG.md || { echo "::error::CHANGELOG.md has no entry for $VERSION"; exit 1; }
ENTRY=$(grep -m1 "^## \[$VERSION\]" CHANGELOG.md) || { echo "::error::CHANGELOG.md has no entry for $VERSION"; exit 1; }
echo "CHANGELOG: $ENTRY"
# a release needs its date in the entry (YYYY-MM-DD), not 'unreleased'
if [ "${{ github.event_name }}" = "push" ] && ! echo "$ENTRY" | grep -qE "[0-9]{4}-[0-9]{2}-[0-9]{2}"; then
echo "::error::The CHANGELOG entry of $VERSION has no release date: $ENTRY"; exit 1
fi
4 changes: 2 additions & 2 deletions docs/release_1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ Every other branch gets its own preview after a push: `https://rwth-ebc.github.i
v1.0.0 → Target: main*, release notes from `CHANGELOG.md`). Keep the **known limitation** at the top of the release
notes: the export of `RNNModel` / `PCNNModel` needs AgentLib-MPC branch `143-feature-multistep-model-support`.
2. The workflow **Version Check** runs on the tag: it compares the tag with `version = "1.0.0"` in `pyproject.toml`
and requires the entry `## [1.0.0]` in `CHANGELOG.md`. Before tagging, replace `unreleased` in that entry by the
release date in a commit on `main`.
and requires the entry `## [1.0.0]` in `CHANGELOG.md` with a release date (`YYYY-MM-DD`). Before tagging, replace
`unreleased` in that entry by the release date in a commit on `main`, else the check fails on the tag.
3. PyPI is not used yet (`PYPI_RELEASE: false` in `ci.yml`).
4. Later releases: update `constraints.txt` to the versions the release was tested with (the job `tested-versions`
of `windows.yml` installs them on Linux and Windows).
Expand Down
37 changes: 23 additions & 14 deletions physXAI/io/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@ def save(
Returns:
dict: The written files by kind ('preprocessing', 'model_config', 'training_data', 'model', 'plot').
"""
if model is not None and trained_model is None:
trained_model = getattr(model, "trained_model", None)
plot = plot and model is not None and getattr(training_data, "metrics", None) is not None
# check all files before writing any, so a run without overwrite is not left half written
targets = [
*([f"{name}_preprocessing.json"] if preprocessing is not None else []),
*([f"{name}_model.json"] if model is not None else []),
*([f"{name}_training_data.json"] if training_data is not None else []),
*([f"{name}.keras", f"{name}.joblib"] if model is not None and trained_model is not None else []),
*([f"{name}.html"] if plot else []),
]
for target in targets:
self.file(target) # raises if the file exists and the run does not overwrite

files: dict[str, Path] = dict()
version = self.version_info()
if preprocessing is not None:
Expand All @@ -193,20 +207,15 @@ def save(
files["training_data"] = self.save_json(
f"{name}_training_data.json", {**training_data.get_config(), **version}
)
if model is not None:
if trained_model is None:
trained_model = getattr(model, "trained_model", None)
if trained_model is not None:
for suffix in (".keras", ".joblib"):
self.file(name + suffix) # raises if the model exists and the run does not overwrite
model.save_model(trained_model, str(self.path / name))
saved = [self.path / (name + s) for s in (".keras", ".joblib") if (self.path / (name + s)).exists()]
if saved:
files["model"] = saved[0]
if plot and training_data is not None and getattr(training_data, "metrics", None) is not None:
from physXAI.plotting.plotting import save_figure

files["plot"] = save_figure(model.plot(training_data), self.file(f"{name}.html"))
if model is not None and trained_model is not None:
model.save_model(trained_model, str(self.path / name))
saved = [self.path / (name + s) for s in (".keras", ".joblib") if (self.path / (name + s)).exists()]
if saved:
files["model"] = saved[0]
if plot:
from physXAI.plotting.plotting import save_figure

files["plot"] = save_figure(model.plot(training_data), self.file(f"{name}.html"))
logger.info("Saved '%s' in %s", name, self.path)
return files

Expand Down
15 changes: 13 additions & 2 deletions physXAI/preprocessing/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ def __init__(
if output is None:
raise ValueError("An output is required.")
self.inputs: list[str] | None = expand_lags(list(inputs), lags) if inputs is not None else None
self._lags = lags if inputs is None else None
# lags of inputs taken from the model, expanded in `resolve_inputs` (until then part of the config)
self._lags: dict[str, int] | None = dict(lags) if inputs is None and lags else None
self.output: list[str] = [output] if isinstance(output, str) else list(output)
from physXAI.expr import from_config as expr_from_config

Expand Down Expand Up @@ -528,6 +529,7 @@ def resolve_inputs(self, model=None) -> list[str]:
if required is None:
raise ValueError("No inputs given: pass inputs or a model with an expression graph to pipeline().")
self.inputs = order_inputs(expand_lags(required, self._lags), self.output)
self._lags = None # part of the inputs now
elif required is not None:
missing = [name for name in required if name not in self.inputs]
if missing:
Expand Down Expand Up @@ -560,6 +562,8 @@ def get_config(self) -> dict:
return {
"__class_name__": self.__class__.__name__,
"inputs": self.inputs,
# only set while the inputs are not resolved yet (taken from the model), else the lags are part of inputs
"lags": self._lags,
"output": self.output,
"columns": {name: expr_to_config(e) for name, e in self.columns.items()} or None,
"shift": self.shift,
Expand Down Expand Up @@ -615,9 +619,16 @@ def _is_multiple(value: float, base: float) -> bool:


def _resolve_steps(value: int | str | None, time_step: float) -> int | None:
"""Converts a number of steps or a time delta string ('24h') into steps."""
"""
Converts a number of steps or a time delta string ('24h') into steps. Whole floats (e.g. 96.0) are steps as well,
other numbers raise a `ValueError` (pandas would read a number as nanoseconds).
"""
if value is None or isinstance(value, (int, np.integer)):
return value
if isinstance(value, (float, np.floating)):
if not float(value).is_integer():
raise ValueError(f"A number of steps has to be whole, got {value}. Use a time delta string, e.g. '90min'.")
return int(value)
return int(round(pd.Timedelta(value).total_seconds() / time_step))


Expand Down
7 changes: 7 additions & 0 deletions tests/test_io/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,12 @@ def test_save(tmp_path, one_room_csv):
assert isinstance(loaded, LinearRegressionModel)
with pytest.raises(FileExistsError):
run.save("lr", prep, m, td)
# an existing model file stops the save before any config is written
partial = Run(tmp_path, "partial")
(partial.path / "lr.joblib").write_bytes(b"old")
with pytest.raises(FileExistsError, match="lr.joblib"):
partial.save("lr", prep, m, td)
assert sorted(p.name for p in partial.path.iterdir()) == ["lr.joblib"]
assert (partial.path / "lr.joblib").read_bytes() == b"old"
# only configs, without a trained model
assert set(Run(tmp_path, "configs").save("x", prep, LinearRegressionModel())) == {"preprocessing", "model_config"}
32 changes: 32 additions & 0 deletions tests/test_preprocessing/test_preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ def test_config_roundtrip_reproduces_the_split(frame, tmp_path):
assert td.eval_horizon == td2.eval_horizon == 24


def test_lags_of_model_inputs_survive_the_config(frame, tmp_path):
"""Lags of inputs taken from the model are kept in the config until the inputs are resolved (repeat_training)."""
from physXAI import expr as px
from physXAI.models import ModularANN

v = px.Vars()
model = ModularANN(px.Linear([v.mDot, v.T]), solver="ols")
prep = PreprocessingSingleStep(output="Change(T)", lags={"T": 2}, random_state=0)
restored = PreprocessingData.from_config(json.loads(json.dumps(prep.get_config())))
assert restored.get_config()["lags"] == {"T": 2}

path = _csv(frame, tmp_path)
prep.pipeline(path, model=model)
restored.pipeline(path, model=model)
assert restored.inputs == prep.inputs == ["mDot", "T", "T_lag1", "T_lag2"]
# once resolved, the lags are part of the inputs
config = prep.get_config()
assert config["lags"] is None
assert PreprocessingData.from_config(config).inputs == prep.inputs


def test_steps_given_as_numbers():
from physXAI.preprocessing.preprocessing import _resolve_steps

assert _resolve_steps(96, 900.0) == 96
assert _resolve_steps(96.0, 900.0) == 96 # not 96 ns
assert _resolve_steps(np.float64(4.0), 900.0) == 4
assert _resolve_steps("1h", 900.0) == 4
with pytest.raises(ValueError, match="whole"):
_resolve_steps(2.5, 900.0)


def test_global_seed():
physXAI.set_seed(5)
try:
Expand Down
Loading