Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c70ff74
Updated transform 'spherical_to_cartesian' for the case when a vector…
eisDNV Feb 4, 2026
50bb05b
Upgrade of the utils.controls module. Code-breaking changes.
eisDNV Feb 23, 2026
9f288a3
Improvement to the Control class, using the Protocol class to impleme…
eisDNV Feb 24, 2026
8b477f2
Corrected problem with proper tracking of initial conditions when set…
eisDNV Feb 25, 2026
081a302
Merge branch 'main' into eis-review-claas
ClaasRostock Feb 25, 2026
8da56ea
tests/conftest.py: Change scope of top level fixtures from "package" …
ClaasRostock Feb 20, 2026
5eef2d2
README.rst: minor formatting change
ClaasRostock Feb 20, 2026
762c9d9
pyproject.toml: Moved scipy>=1.16 from optional dev dependencies to p…
ClaasRostock Feb 20, 2026
6946333
updated uv.lock
ClaasRostock Feb 20, 2026
324e2fc
src/component_model/variable.py: minor formatting
ClaasRostock Feb 24, 2026
2dcc5c8
src/component_model/utils/fmu.py: Minor formatting (removed empty lines)
ClaasRostock Feb 24, 2026
cdf8878
examples/axle_fmu.py: Corrected a typo in Variable description text.
ClaasRostock Feb 24, 2026
c5f363e
updated uv.lock
ClaasRostock Feb 25, 2026
be9427c
src/component_model/utils/controls.py: Slightly rephrased the docstri…
ClaasRostock Feb 25, 2026
f71759b
ruff format
ClaasRostock Feb 25, 2026
13e2fe6
tests/test_working_directory/.gitignore: Added *.dat to .gitignore in…
ClaasRostock Feb 25, 2026
d922a7c
remove FMU files from `/examples`. No need to persist the FMUs as bin…
ClaasRostock Feb 25, 2026
4daf305
tests/test_oscillator_fmu.py and tests/test_oscillator_6dof_fmu.py: C…
ClaasRostock Feb 25, 2026
0f150a8
Minorr changes to follow up work in py-crane
eisDNV Mar 11, 2026
9d0c6ba
Minorr changes to follow up work in py-crane
eisDNV Mar 11, 2026
63cc573
Allign with changes from another branch
eisDNV Mar 11, 2026
7b799bc
updated uv.lock
ClaasRostock Mar 11, 2026
dda30e6
Updated CHANGELOG.md
ClaasRostock Mar 11, 2026
500aab1
bumped version number to 0.4.0
ClaasRostock Mar 11, 2026
b2680bb
updated uv.lock
ClaasRostock Mar 11, 2026
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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ The changelog format is based on [Keep a Changelog](https://keepachangelog.com/e
* -/-


## [0.4.0] - 2026-03-12

### Breaking Changes
* src/component_model/utils/controls.py: Refactored `Controls` from a multi-variable container into per-variable `Control` objects with typed read/write access, and improved goal tracking so initial speed and acceleration are handled correctly when setting or changing goals.
* src/component_model/utils/analysis.py: Reworked sine fitting by moving the `sine_fit()` function from `component_model.analytic` into `component_model.utils.analysis` and expanding it to fit offset sine waves, detect cycles from maxima, support cosine-like starts, and return offset, amplitude, angular frequency, phase, and a reference mid-time.

### Resolved
* Fixed coordinate transform handling for vectors pointing in the negative z-direction.

### Changed
* Relaxed range compatibility checks for units and promoted scipy from a test-only dependency to a runtime package dependency, aligning dependencies with actual package usage.
* Cleaned up FMU test artifacts by removing generated FMU binaries from the examples folder, building FMUs into the test working directory instead, ignoring generated .dat files, and switching shared test fixtures to session scope.
* Added or updated tests for the new controls behavior, sine fitting, transform handling, and FMU build workflow.
* Included minor follow-up cleanup such as corrected example text typos, small README/docstring/formatting updates, and lockfile refreshes.


## [0.3.2] - 2026-02-02

### Added
Expand Down Expand Up @@ -139,7 +155,8 @@ The changelog format is based on [Keep a Changelog](https://keepachangelog.com/e


<!-- Markdown link & img dfn's -->
[unreleased]: https://github.com/dnv-innersource/component-model/compare/v0.3.2...HEAD
[unreleased]: https://github.com/dnv-innersource/component-model/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/dnv-innersource/component-model/compare/v0.3.2...v0.4.0
[0.3.2]: https://github.com/dnv-innersource/component-model/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dnv-innersource/component-model/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dnv-innersource/component-model/compare/v0.2.0...v0.3.0
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
title: component-model
version: 0.3.2
version: 0.4.0
abstract: >-
Constructs a Functional Mockup Interface component model from a python script (fulfilling some requirements).
type: software
Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ Development Setup

1. Install uv
^^^^^^^^^^^^^
This project uses `uv` as package manager.
This project uses ``uv`` as package manager.

If you haven't already, install `uv <https://docs.astral.sh/uv/>`_, preferably using it's `"Standalone installer" <https://docs.astral.sh/uv/getting-started/installation/#__tabbed_1_2/>`_ method:

Expand All @@ -319,7 +319,7 @@ If you haven't already, install `uv <https://docs.astral.sh/uv/>`_, preferably u

(see `docs.astral.sh/uv <https://docs.astral.sh/uv/getting-started/installation//>`_ for all / alternative installation methods.)

Once installed, you can update `uv` to its latest version, anytime, by running:
Once installed, you can update ``uv`` to its latest version, anytime, by running:

.. code:: sh

Expand Down Expand Up @@ -381,7 +381,7 @@ Run ``uv sync -U`` to create a virtual environment and install all project depen
**Note**: ``uv`` will create a new virtual environment called
``.venv`` in the project root directory when running ``uv sync -U``
the first time. Optionally, you can create your own virtual
environment using e.g. ``uv venv``, before running ``uv sync -U``.
environment using e.g. ``uv venv``, before running ``uv sync -U``.


5. (Optional) Activate the virtual environment
Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
author = "Siegfried Eisinger, Jorge Luis Mendez"

# The full version, including alpha/beta/rc tags
release = "0.3.2"
release = "0.4.0"

# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
Expand Down
Binary file removed examples/DrivingForce.fmu
Binary file not shown.
Binary file removed examples/DrivingForce6D.fmu
Binary file not shown.
Binary file removed examples/HarmonicOscillator.fmu
Binary file not shown.
Binary file removed examples/HarmonicOscillator6D.fmu
Binary file not shown.
8 changes: 4 additions & 4 deletions examples/axle_fmu.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,23 @@ def __init__(
self._rpm0 = Variable(
self,
"wheels[0].motor.rpm",
"Angualar speed of wheel 1 in rad/s:",
"Angular speed of wheel 1 in rad/s:",
causality="input",
variability="continuous",
start=f"{rpm1} 1/s",
)
self._rpm1 = Variable(
self,
"wheels[1].motor.rpm",
"Angualar speed of wheel 2 in rad/s:",
"Angular speed of wheel 2 in rad/s:",
causality="input",
variability="continuous",
start=f"{rpm2} 1/s",
)
self._acc0 = Variable(
self,
"der(wheels[0].motor.rpm)",
"Angualar acceleration of wheel 1 in rad/s**2:",
"Angular acceleration of wheel 1 in rad/s**2:",
causality="input",
variability="continuous",
start="0.0 1/s**2",
Expand All @@ -90,7 +90,7 @@ def __init__(
Variable(
self,
"der(wheels[1].motor.rpm)",
"Angualar acceleration of wheel 2 in rad/s**2:",
"Angular acceleration of wheel 2 in rad/s**2:",
causality="input",
variability="continuous",
start="0.0 1/s**2",
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ allow-direct-references = true

[project]
name = "component-model"
version = "0.3.2"
version = "0.4.0"
description = "Constructs a Functional Mockup Interface component model from a python script (fulfilling some requirements)."
readme = "README.rst"
requires-python = ">= 3.11"
Expand Down Expand Up @@ -66,13 +66,12 @@ dependencies = [
"jsonpath-ng>=1.7.0",
"pythonfmu>=0.7.0",
"flexparser>=0.4",
"scipy-stubs>=1.17.0.0",
"scipy>=1.16",
]

[project.optional-dependencies]
test = [
"fmpy==0.3.21", # version 0.3.22 does so far (25.3.25) not workwhen installing through pip
"scipy>=1.16",
"fmpy>=0.3.21", # version 0.3.22 does so far (25.3.25) not workwhen installing through pip
"matplotlib>=3.10",
"plotly>=6.3",
"libcosimpy>=0.0.5",
Expand Down Expand Up @@ -103,6 +102,7 @@ dev = [
"sphinxcontrib-mermaid>=1.0.0",
"myst-parser>=4.0",
"furo>=2025.9",
"scipy-stubs>=1.17.0.0",
]

[tool.uv]
Expand Down
56 changes: 0 additions & 56 deletions src/component_model/analytic.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,59 +163,3 @@ def period(self):
return 2 * np.pi / self.wd
else:
return float("inf")


def sine_fit(times: list[float] | np.ndarray, vals: list[float] | np.ndarray, max_pcov: float = 1e-2):
"""Fit a general sine function f(t) = a* sin(w*t + phi) to the data and return (a,omega,phi).

* The last two upward zero-crossings of the data set are detected and a full sine wave is fit to that.
* Error is issued if zero-crossings are not found.
* Warning is provided if the fit to the sine function is bad (diag(pcov) > max_pcov).
* The phase angle is returned in the range ]-pi, pi]
"""
from scipy.optimize import curve_fit

def do_fit(x: np.ndarray, y: np.ndarray):
"""Perform a sind function fit on the given data and return parameters."""

def func(x: float | np.ndarray, a: float, w: float, phi: float, /):
return a * np.sin(w * x + phi)

popt, pcov = curve_fit(func, x, y)
if max(np.diag(pcov)) > max_pcov:
logger.warning(f"Curve cannot be fitted well to sine function: {np.diag(pcov)}. Detected to:{x[0]}")
return popt

times = np.array(times, float)
vals = np.array(vals)
zero0, zero1, previous = None, None, None
for t, v in reversed(list(zip(times, vals, strict=True))):
if previous is not None:
if np.sign(v) == 0 and np.sign(previous[1]) == 1:
if zero1 is None:
zero1 = list(times).index(t)
else:
zero0 = list(times).index(t)
break
elif np.sign(v) == -1 and np.sign(previous[1]) == 1:
if zero1 is None:
zero1 = list(times).index(previous[0])
else:
zero0 = list(times).index(t)
break
previous = (t, v)
assert zero0 is not None and zero1 is not None, "Zeroes not found"
t0 = times[zero0]

a, w, phi = do_fit(times[zero0 : zero1 + 1] - t0, vals[zero0 : zero1 + 1])
if w < 0:
a, w, phi = -a, -w, -phi
if a < 0:
a = -a
phi += np.pi
phi -= w * t0 % (2 * np.pi)
while phi > np.pi - 1e-14:
phi -= 2 * np.pi
while phi <= -np.pi + 1e-14:
phi += 2 * np.pi
return a, w, phi
10 changes: 6 additions & 4 deletions src/component_model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,10 +651,12 @@ def _xml_structure_initialunknowns(self):
init = ET.Element("InitialUnknowns")

for v in filter(
lambda v: v is not None
and (
(v.causality == Causality.output and v.initial in (Initial.approx, Initial.calculated))
or (v.causality == Causality.calculatedParameter)
lambda v: (
v is not None
and (
(v.causality == Causality.output and v.initial in (Initial.approx, Initial.calculated))
or (v.causality == Causality.calculatedParameter)
)
),
self.vars.values(),
):
Expand Down
2 changes: 1 addition & 1 deletion src/component_model/range.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __init__(
l_rng[i] = val # type: ignore[reportArgumentType] ## l_rng is not empty # fixed display value
else:
assert isinstance(r, (str, int, bool, float, Enum)), f"Found type {type(r)}"
check, q = unit.compatible(r, no_unit=False, strict=True) # q in base units
check, q = unit.compatible(r, no_unit=False, strict=False) # q in base units
if not check:
raise ValueError(f"Provided range {rng}[{i}] is not conformant with unit {unit}") from None
assert isinstance(q, (int, bool, float)), "Unexpected type {type(q)} in {rng}[{i}]"
Expand Down
85 changes: 84 additions & 1 deletion src/component_model/utils/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def w_1(x: float):
def w0(x: float):
return abs(x)

assert len(t) == len(y) > 2, "Something wrong with lengths of t ({len(t)}) and y ([len(y)})"
assert len(t) == len(y), f"Length of sequences t:{len(t)} != y:{len(y)}"
assert len(t) > 2, f"Sequences t and y shall be longer than 2, but length was {len(t)} for both"
if which == "max":
w = w1
elif which == "min":
Expand All @@ -58,3 +59,85 @@ def w0(x: float):
if e != 0 and w(e) == 1 and p[0] < t[i]:
res.append(p)
return res


def sine_fit(times: list[float] | np.ndarray, vals: list[float] | np.ndarray, eps: float = 1e-2):
"""Fit a general sine function f(t) = y0 + a* sin(w*t + phi) to the data end and return (y0, a,omega,phi).

* The last two maximum points of the data set are detected and a full sine wave is fit to that.
* Error is issued if maximum points are not found.
* Warning is provided if the fit to the sine function is bad (diag(pcov) > eps).
* If the curve starts with maxima (cos(...)), it is accepted if the first points fit a 2nd order within eps.
* The phase angle is returned in the range ]-pi, pi]
* Returns the zero-line, the amplitude, the angualar frequency, the phase and the mid-time of the wave.
"""
from scipy.optimize import curve_fit

def do_fit(x: np.ndarray, y: np.ndarray):
"""Perform a sin function fit on the given data and return parameters."""

def func(x: float | np.ndarray, y0: float, a: float, w: float, phi: float, /):
return y0 + a * np.sin(w * x + phi)

avg = np.average(y)
popt, pcov = curve_fit(func, x, y, p0=(avg, y[0] - avg, 2 * np.pi / (x[-1] - x[0]), np.pi / 2))
if max(np.diag(pcov)) > eps:
logger.warning(f"Curve cannot be fitted well to sine function: {np.diag(pcov)}. Detected t0:{x[0]}")
return popt

def np_index(arr: np.ndarray, t: float):
"""Get the closest index with respect to a value in the array."""
return np.absolute(arr - t).argmin()

times = np.array(times, float)
vals = np.array(vals)
state = 1
top1, top0 = None, None
for t1, v1, t2, v2 in reversed(list(zip(times[:-1], vals[:-1], times[1:], vals[1:], strict=True))):
if state == 1: # upward slope at end of curve
if v2 <= v1:
state = 2
elif state == 2: # decreasing curve. Search for last top point
if v2 > v1: # found the last top point
top1 = np_index(times, t2)
state = 3
elif state == 3: # increasing curve
if v2 <= v1:
state = 4
elif state == 4:
if v2 > v1: # found the last top point
top0 = np_index(times, t1)
state = 5
elif t1 == times[0]: # first point and first maximum not yet identified. Check whether max(2nd order)
t3 = times[2]
v3 = vals[2]
c = ((t3 - t1) * (v2 - v1) - (t2 - t1) * (v3 - v1)) / (
(t3 - t1) * (t2**2 - t1**2) - (t2 - t1) * (t3**2 - t1**2)
)
b = ((v2 - v1) - c * (t2**2 - t1**2)) / (t2 - t1)
a = v1 - b * t1 - c * t1**2
if abs(b + 2 * c * t1) < eps and c < 0: # accept as start of curve
top0 = 0
state = 5
elif state == 5:
break
if state == 4:
raise AssertionError(f"Only one maximum {vals[top1]}@{times[top1]} found.") from None
elif state < 4:
raise AssertionError("Maxima not found") from None
t0 = times[top0]

y0, a, w, phi = do_fit(times[top0:top1] - t0, vals[top0:top1])
phi -= w * t0 # correct for the time translation
if w < 0:
a, w, phi = -a, -w, -phi
if a < 0:
a = -a
phi += np.pi
# phi -= w * t0 % (2 * np.pi)
while phi > np.pi - 1e-14:
phi -= 2 * np.pi
while phi <= -np.pi + 1e-14:
phi += 2 * np.pi
assert top0 is not None and top1 is not None
return (y0, a, w, phi, times[int((top0 + top1) / 2)])
Loading