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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Strings are converted only when the destination column needs it. DateTime columns parse them; Double columns accept a single `.` or `,` as the decimal separator, so `"1,234"` is 1.234, and anything else raises `ValueError` naming the field. An empty string becomes `None` for both. Strings bound for other columns are passed through unchanged.
- Minimum versions now follow [SPEC 0](https://scientific-python.org/specs/spec-0000/): numpy 2.3, pandas 2.3 and pythonnet 3.0.5.
- Importing `mikeplus` with anything other than x64 Python on Windows or Linux now raises `ImportError`.
- `Database.run()` raises `RuntimeError` when the engine exits with an error or doesn't write its result files, naming the engine's logs (#143).
- `TopoRepairTool.run()` raises `ValueError` unless the active model is CS_MIKE1D or WD_EPANET, instead of doing nothing (#146).
- `CathSlopeLengthProcess.run()` reports the tool's warnings with `warnings.warn` (#145).
- `ScenarioCollection.find_by_name()` returns every scenario with the name, not at most one (#146).
- `Database.close()` returns `None` whether or not the database was open (#146).
- The documentation site moved to great-docs. API reference pages moved from `api/` to `reference/`, and the auto-generated table classes no longer have their own pages. User guide URLs are unchanged.

### Removed
Expand All @@ -42,6 +47,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Inserting a row with no field values on MIKE+ 2026 Update 1.
- `InterpolationTool.interpolate_from_neighobour` always raised `AttributeError`; it now honours `alongPath`.
- The `InterpolationTool` methods raised `TypeError` when `value_as_missing` was a number.
- `Database.create(overwrite=True)` deleted the existing database before rejecting `srid` together with `projection_string` (#140).
- `to_sql()` and `by_muid()` didn't escape single quotes, and `to_sql(True)` gave `True` rather than `1` (#144).
- Progress from `CathSlopeLengthProcess` and `ConnectionRepairTool` was never printed, because the handler was attached after the tool ran (#145).
- `Alternative.scenarios` raised `AttributeError` when the model had a scenario other than Base, and it, like `Alternative ==`, confused alternatives of different groups that share an id (#142).
- Iterating `db.scenarios` returned scenarios with the same name as one scenario repeated, and iterating an `AlternativeGroup` skipped alternatives below the base's children (#146).
- User guide examples and docstrings that raised or contradicted the code, notably `db.run(sim_option=...)` (not `model_option`), `insert()` running immediately, single-use queries, and `db.alternative_groups` (not `db.scenarios.alternative_groups`).

## [2026.0.0] - 2026-01-29
Expand Down
32 changes: 32 additions & 0 deletions docs/dictionary.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Words great-docs proofread should accept: domain terms, and identifiers that
# appear in prose and docstrings. One per line; matching is case-sensitive.
ActiveProduct
Alloc
alongPath
AlternativeGroup
AlternativeGroupCollection
AltId
AssignOption
BaseNodeTable
BaseQuery
Expand All @@ -18,18 +20,27 @@ CatchSlopeLengthProcess
CathSlopeLengthProcess
chainage
ClosestNode
Coeff1
Coeff4
ColumnName
configFile
conn
ConnectionRepairTool
ConnectionTypeNo
CreateValvesFromPointsTool
DatabaseError
DataFrame
DataFrames
DataTableContainer
DataTables
DBFilePath
DbType
DeleteQuery
DEM
dem
DemAlloc
DemAllocConn
DemandAggregationTool
DemandConnectionTool
DEMs
demUnitKey
Expand All @@ -48,12 +59,17 @@ DTZ001
EngineTool
EPANET
epanet
ETRS89
f
F401
FCV
FileExistsError
GA
GenerateTypeNo
GetMuidAndFieldsWhereOrder
GetScenarios
getsourcefile
GPV
GroundLevel
IAlternative
IAlternativeGroup
Expand All @@ -71,10 +87,15 @@ InstallRoot
InterpolationTool
IScenario
IScenarioManager
ItemsView
JobList
JunctionID
KeysView
LD
len
LoadPointConnection
MaxValueNeighbours
MDemand
MIKE1D
MikeImport
mikeio
Expand All @@ -100,6 +121,12 @@ noqa
NullReference
parameterless
pathlib
PathLike
PBV
PFS
PipeID
PRV
PSV
Pythonnet
pythonnet
QueryResultT
Expand All @@ -114,10 +141,12 @@ SimulationRunner
sqlite
SRID
srid
StationCon
SWMM
swmm
TableCollection
tabset
TCV
testdata
tjunction
TODO
Expand All @@ -130,9 +159,12 @@ UpdateQuery
UpstreamElement
UpstreamMaxValue
UpstreamMinValue
UTM
uv
ValuesView
VC
venv
VTYPE
WD
WGS84
WindowsDesktop
Expand Down
2 changes: 1 addition & 1 deletion docs/user_guide/02-database.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ with mp.open("model.sqlite") as db:
It returns the existing project file if there is one and never changes it. A new one records the database's model type and unit system, and refers to the database by a path relative to itself, so keep the two files together when you move the model.

::: {.callout-warning}
`mp.create(..., overwrite=True)` deletes an existing `.mupp` as well as the database. Settings MIKE+ keeps in a `.mupp`, such as map layers, symbology and the map's coordinate system, are lost.
`mp.create(..., overwrite=True)` deletes the `.mupp` of that name as well as the database. Settings MIKE+ keeps in a `.mupp`, such as map layers, symbology and the map's coordinate system, are lost.
:::

### General Database Properties
Expand Down
8 changes: 3 additions & 5 deletions docs/user_guide/06-simulations.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,13 @@ for path in result_paths:

## {{< icon triangle-alert >}} Error Handling

`db.run()` raises `RuntimeError` if the engine fails to start, and `ValueError` if `sim_option` is invalid or cannot be derived from the active model.

Errors that occur while the engine is running are not raised. Check the simulation log, or that the result files exist, before using the results.
`db.run()` raises `RuntimeError` if the engine fails to start, exits with an error, or doesn't write its result files; the message names the engine's logs. It raises `ValueError` if `sim_option` is invalid or cannot be derived from the active model.

```python
try:
result_files = db.run()
except RuntimeError as e:
print(f"Simulation failed to start: {e}")
print(f"Simulation failed: {e}")
```

## {{< icon file-code >}} Example
Expand All @@ -75,7 +73,7 @@ with mp.open("path/to/your_model.sqlite") as db:
try:
results = db.run()
except RuntimeError as e:
print(f"Simulation failed to start: {e}")
print(f"Simulation failed: {e}")
else:
missing = [path for path in results if not path.exists()]
if missing:
Expand Down
33 changes: 10 additions & 23 deletions mikeplus/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from .scenarios.scenario import Scenario

from pathlib import Path
from plistlib import InvalidFileException

from DHI.Amelia.DataModule.Services.DataSource import BaseDataSource
from DHI.Amelia.DataModule.Services.DataSource.ScenarioMangement import (
Expand Down Expand Up @@ -59,8 +58,6 @@ def __init__(self, model_path: str | Path, *, auto_open: bool = True):
------
FileNotFoundError
If the database file doesn't exist
InvalidFileException
If the model file is invalid

"""
model_path = Path(model_path)
Expand All @@ -79,11 +76,8 @@ def __init__(self, model_path: str | Path, *, auto_open: bool = True):
if db_file.exists():
self._db_path = db_file

if not (self._db_path or self._mupp_path):
raise InvalidFileException(f"Model file '{model_path}' is invalid.")

resolved = self._db_path.resolve()
# PFS resolves a .mupp's relative DBFilePath wrongly from a forward-slash path
# PFS resolves the relative DBFilePath in a .mupp wrongly from a forward-slash path
self._data_source: BaseDataSource = BaseDataSource.Create(
str(resolved) if resolved.suffix.lower() == ".mupp" else resolved.as_posix()
)
Expand Down Expand Up @@ -159,6 +153,9 @@ def create(
db_sqlite = model_path.with_suffix(".sqlite")
db_mupp = model_path.with_suffix(".mupp")

if projection_string and srid != -1:
raise ValueError("Projection string and SRID cannot be specified together.")

if overwrite:
model_path.unlink(missing_ok=True)
db_sqlite.unlink(missing_ok=True)
Expand All @@ -167,9 +164,6 @@ def create(
if model_path.exists() or db_sqlite.exists() or db_mupp.exists():
raise FileExistsError(f"Model file '{model_path}' already exists.")

if projection_string and srid != -1:
raise ValueError("Projection string and SRID cannot be specified together.")

try:
data_source = BaseDataSource.Create(str(db_sqlite))
data_source.CreateDatabase()
Expand Down Expand Up @@ -224,21 +218,16 @@ def open(self) -> Self:

return self

def close(self) -> bool | None:
"""Close the model database.

Returns
-------
bool or None
True if the database was already closed, otherwise None.
def close(self) -> None:
"""Close the model database. Closing a closed database does nothing.

Raises
------
DatabaseError
If MIKE+ fails to close the database
"""
if not self._is_open:
return True
return

try:
self._data_table_container.UndoRedoManager.ClearUndoRedoBuffer()
Expand All @@ -248,7 +237,6 @@ def close(self) -> bool | None:
raise DatabaseError(
f"Failed to close model database: {self._db_path}.\n{e!s}"
)
return None

def ensure_mupp(self) -> Path:
"""Get the model's project file (.mupp), writing one if it has none.
Expand Down Expand Up @@ -618,17 +606,16 @@ def run(
Returns
-------
list[Path]
Paths to the result files the simulation is configured to write. They
are not checked for existence; errors during the simulation itself
are not raised.
Paths to the result files the simulation wrote.

Raises
------
ValueError
If `sim_option` is invalid, or not given and the active model does not
determine one.
RuntimeError
If the simulation engine fails to start.
If the simulation engine fails to start, exits with an error, or
doesn't write its result files. The message names the engine's logs.
"""
return self._runner.run(simulation_muid, sim_option=sim_option)

Expand Down
2 changes: 1 addition & 1 deletion mikeplus/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def by_muid(self, muid_or_muids: str | list[str] | tuple[str]) -> Self:
If `muid_or_muids` is an empty list/tuple.
"""
if isinstance(muid_or_muids, str):
self.where(f"MUID = '{muid_or_muids}'")
self.where(f"MUID = {to_sql(muid_or_muids)}")
elif isinstance(muid_or_muids, (list, tuple)):
if not muid_or_muids:
raise ValueError(
Expand Down
25 changes: 14 additions & 11 deletions mikeplus/scenarios/alternative.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def __eq__(self, other: object) -> bool:
"""Check if two alternatives are equal."""
if not isinstance(other, Alternative):
return False
return self.id == other.id
return self.id == other.id and self.group.id == other.group.id

@property
def id(self) -> int:
Expand Down Expand Up @@ -146,13 +146,16 @@ def comment(self, value: str) -> None:
def scenarios(self) -> list[Scenario]:
"""Scenarios that use this alternative."""
from .scenario import Scenario

scenarios = []
for scenario_id in self._scenario_manager.GetScenarios():
scenario = self._scenario_manager.FindScenario(scenario_id)
for alt in scenario.CurAlternatives:
if alt.AltId == self._net_alternative.AltId:
scenarios.append(Scenario(self._scenario_manager, scenario))
break

return scenarios
from .scenario_collection import iter_net_scenarios

# AltId is only unique within a group.
alt_id = self._net_alternative.AltId
group_id = self._net_alternative.Group.Id
return [
Scenario(self._scenario_manager, scenario)
for scenario in iter_net_scenarios(self._scenario_manager)
if any(
alt.AltId == alt_id and alt.Group.Id == group_id
for alt in scenario.CurAlternatives
)
]
9 changes: 6 additions & 3 deletions mikeplus/scenarios/alternative_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,12 @@ def __repr__(self) -> str:
return f"<AlternativeGroup {self.name}>"

def __iter__(self) -> Iterator[Alternative]:
"""Iterate through all alternatives in this group."""
yield self.base
yield from self.base.children
"""Iterate through all alternatives in this group, parents before children."""
stack = [self.base]
while stack:
alt = stack.pop()
yield alt
stack.extend(reversed(alt.children))

def _find_by_name(
self, name: str, parent: IAlternative, found: list[IAlternative]
Expand Down
30 changes: 16 additions & 14 deletions mikeplus/scenarios/scenario_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
from .scenario import Scenario


def iter_net_scenarios(scenario_manager: Any) -> Iterator[Any]:
"""Yield every .NET scenario, depth first from the base."""
# GetScenarios returns names, which aren't unique, so walk the tree instead.
stack = [scenario_manager.BaseScenario]
while stack:
scenario = stack.pop()
yield scenario
stack.extend(reversed(list(scenario.Children)))


class ScenarioCollection:
"""Collection-like access to scenarios in the database.

Expand All @@ -25,7 +35,7 @@ class ScenarioCollection:
Methods
-------
find_by_name(name) : list[Scenario]
Find a scenario by name, as a list of at most one
Find all scenarios with the given name
by_name(name) : Scenario or None
Find first scenario that matches the given name
find_by_id(id) : Scenario or None
Expand Down Expand Up @@ -97,15 +107,12 @@ def __getitem__(self, id: str) -> Scenario:
return Scenario(self._scenario_manager, scenario)

def __iter__(self) -> Iterator[Scenario]:
"""Iterate through all scenarios."""
for scenario_id in self._scenario_manager.GetScenarios():
scenario = self._scenario_manager.FindScenarioByName(scenario_id)
"""Iterate through all scenarios, parents before their children."""
for scenario in iter_net_scenarios(self._scenario_manager):
yield Scenario(self._scenario_manager, scenario)

def find_by_name(self, name: str) -> list[Scenario]:
"""Find a scenario by name.

Returns at most one scenario, even if several share the name.
"""Find all scenarios with the given name; MIKE+ allows duplicates.

Parameters
----------
Expand All @@ -115,14 +122,9 @@ def find_by_name(self, name: str) -> list[Scenario]:
Returns
-------
list[Scenario]
A list holding the first scenario with the given name, or an empty
list if none match
The scenarios with the given name, or an empty list if none match
"""
scenario = self._scenario_manager.FindScenarioByName(name)
if scenario is None:
return []

return [Scenario(self._scenario_manager, scenario)]
return [scenario for scenario in self if scenario.name == name]

def by_name(self, name: str) -> Scenario | None:
"""Find a scenario by name (returns the first match if multiple exist).
Expand Down
Loading
Loading