Skip to content
34 changes: 34 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@

This document describes breaking changes in CTModels releases and how to migrate your code.

## [0.9.6] - 2026-03-10

**No breaking changes** - This release adds a dedicated costate time grid while maintaining full backward compatibility.

### New Features (Non-Breaking)

- **4-Grid Time System**: `build_solution` now supports 4 independent time grids
- New signature: `build_solution(ocp, T_state, T_control, T_costate, T_path, X, U, v, P; ...)`
- Legacy signature preserved: `build_solution(ocp, T, X, U, v, P; ...)` still works
- Automatic grid optimization when all grids are identical

- **Costate Grid Independence**: Costate now has its own dedicated time grid
- `time_grid(sol, :costate)` returns costate-specific grid
- `clean_component_symbols((:costate,))` → `(:costate,)` (was `(:state,)` before)
- Enables different discretizations for state and costate (e.g., symplectic integrators)

- **Enhanced Serialization**: Multi-grid format includes `time_grid_costate`
- Backward compatible: old files without `time_grid_costate` use `T_state` as fallback
- Forward compatible: new files with 4 grids work with updated readers

### Migration Notes

**No action required** - All existing code continues to work unchanged:

```julia
# Legacy single-grid code (still works)
sol = build_solution(ocp, T, X, U, v, P; objective=obj, ...)

# New multi-grid code (optional)
sol = build_solution(ocp, T_state, T_control, T_costate, T_path, X, U, v, P; objective=obj, ...)
```

The package automatically detects and handles both formats. All tests pass (3324/3324).

## [0.9.5] - 2026-03-09

**No breaking changes** - This release focuses on internal API cleanup with no impact on public functionality.
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.6] - 2026-03-10

### Added

- **Dedicated Costate Time Grid**: Reintroduced independent `T_costate` time grid for costate trajectories
- `build_solution` now accepts 4 independent time grids: `T_state`, `T_control`, `T_costate`, `T_path`
- Costate can now use a different discretization from state (e.g., for symplectic integrators)
- `MultipleTimeGridModel` extended to include `:costate` grid
- `clean_component_symbols` updated to map `:costate` → `:costate` (own grid)
- `time_grid(sol, :costate)` now returns the costate-specific grid
- All tests passing (3324/3324)

- **Enhanced Serialization**: Multi-grid format now includes `time_grid_costate`
- JSON/JLD export includes dedicated costate grid
- Backward compatibility: files without `time_grid_costate` use `T_state` as fallback
- Automatic format detection and conversion

- **Comprehensive Documentation**: Added detailed docstrings explaining time grid semantics
- `build_solution`: 173 lines of detailed documentation on 4-grid system
- `_serialize_solution`: 128 lines explaining serialization formats
- `_discretize_all_components`: 41 lines on grid-component associations
- Complete examples and usage patterns

### Changed

- **Time Grid Validation**: `time_grid` getter now accepts `:costate` for both `UnifiedTimeGridModel` and `MultipleTimeGridModel`
- **Legacy Signature**: `build_solution(ocp, T, X, U, v, P; ...)` now forwards to 4-grid version with `T_state = T_control = T_costate = T_path = T`
- **Plotting**: Costate now maps to its dedicated grid in `_map_to_time_grid_component`

### Fixed

- **Grid Optimization**: Solutions with identical grids automatically use `UnifiedTimeGridModel` for memory efficiency
- **Test Coverage**: All multi-grid tests updated to use 4-grid signature and verify costate grid independence

## [0.9.5] - 2026-03-09

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "CTModels"
uuid = "34c4fa32-2049-4079-8329-de33c2a22e2d"
version = "0.9.5"
version = "0.9.6"
authors = ["Olivier Cots <olivier.cots@toulouse-inp.fr>"]

[deps]
Expand Down
192 changes: 83 additions & 109 deletions ext/CTModelsJSON.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@ using DocStringExtensions

using JSON3

# ============================================================================
# Private helpers for JSON matrix conversion
# ============================================================================

# Liste des champs matriciels à convertir
const _MATRIX_FIELDS = ["state", "control", "costate"]
const _OPTIONAL_MATRIX_FIELDS = [
"path_constraints_dual",
"state_constraints_lb_dual",
"state_constraints_ub_dual",
"control_constraints_lb_dual",
"control_constraints_ub_dual",
]

"""
Convert Matrix fields to Vector{Vector} for JSON3 export.

JSON3 flattens Matrix{Float64} into 1D arrays, losing the 2D structure.
This function converts all matrix fields to Vector{Vector} format to preserve dimensions.
"""
function _convert_matrices_for_json!(blob::Dict)
# Convert required matrix fields
for key in _MATRIX_FIELDS
if haskey(blob, key) && blob[key] isa Matrix
blob[key] = CTModels.Utils.matrix2vec(blob[key], 1)
end
end

# Convert optional matrix fields (can be nothing)
for key in _OPTIONAL_MATRIX_FIELDS
if haskey(blob, key) && !isnothing(blob[key]) && blob[key] isa Matrix
blob[key] = CTModels.Utils.matrix2vec(blob[key], 1)
end
end
end

# ============================================================================
# Private helper: broadcast with Nothing fallback
# ============================================================================
Expand Down Expand Up @@ -150,36 +186,14 @@ julia> export_ocp_solution(JSON3Tag(), sol; filename="mysolution")
function CTModels.export_ocp_solution(
::CTModels.JSON3Tag, sol::CTModels.Solution; filename::String
)
T = CTModels.time_grid(sol)

blob = Dict(
"time_grid" => CTModels.time_grid(sol),
"state" => _apply_over_grid(CTModels.state(sol), T),
"control" => _apply_over_grid(CTModels.control(sol), T),
"variable" => CTModels.variable(sol),
"costate" => _apply_over_grid(CTModels.costate(sol), T),
"objective" => CTModels.objective(sol),
"iterations" => CTModels.iterations(sol),
"constraints_violation" => CTModels.constraints_violation(sol),
"message" => CTModels.message(sol),
"status" => CTModels.status(sol),
"successful" => CTModels.successful(sol),
"path_constraints_dual" => _apply_over_grid(CTModels.path_constraints_dual(sol), T),
"state_constraints_lb_dual" =>
_apply_over_grid(CTModels.state_constraints_lb_dual(sol), T),
"state_constraints_ub_dual" =>
_apply_over_grid(CTModels.state_constraints_ub_dual(sol), T),
"control_constraints_lb_dual" =>
_apply_over_grid(CTModels.control_constraints_lb_dual(sol), T),
"control_constraints_ub_dual" =>
_apply_over_grid(CTModels.control_constraints_ub_dual(sol), T),
"boundary_constraints_dual" => CTModels.boundary_constraints_dual(sol), # ctVector or Nothing
"variable_constraints_lb_dual" => CTModels.variable_constraints_lb_dual(sol), # ctVector or Nothing
"variable_constraints_ub_dual" => CTModels.variable_constraints_ub_dual(sol), # ctVector or Nothing
)
# Use unified serialization that handles both unified and multiple time grids
blob = CTModels.OCP._serialize_solution(sol)

# Convert Matrix → Vector{Vector} for JSON (to avoid JSON3 flattening)
_convert_matrices_for_json!(blob)

# Serialize infos and get Symbol type metadata
infos_serialized, symbol_keys = _serialize_infos(CTModels.infos(sol))
infos_serialized, symbol_keys = _serialize_infos(blob["infos"])
blob["infos"] = infos_serialized
blob["infos_symbol_keys"] = symbol_keys

Expand All @@ -191,55 +205,35 @@ function CTModels.export_ocp_solution(
end

"""
$(TYPEDSIGNATURES)

Convert JSON3 array data to `Matrix{Float64}` for trajectory import.

# Context
Convert a JSON field (Vector{Vector} via stack) to Matrix{Float64}.

When importing JSON data, `stack(blob[field]; dims=1)` returns different types
depending on the dimensionality of the original trajectory:
- **1D trajectories** (e.g., scalar control): `stack()` → `Vector{Float64}`
- **Multi-D trajectories** (e.g., 2D state): `stack()` → `Matrix{Float64}`

This function normalizes both cases to `Matrix{Float64}` as required by `build_solution`.
JSON exports matrices as Vector{Vector}. After `stack(blob[field]; dims=1)`,
we get either a Matrix (multi-D) or Vector (1D). This normalizes to Matrix.

# Arguments
- `data`: Output from `stack(blob[field]; dims=1)`, either `Vector` or `Matrix`
- `blob_field`: JSON array field (Vector of Vectors)

# Returns
- `Matrix{Float64}`: Properly shaped matrix `(n_time_points, n_dim)` for `build_solution`

# Implementation Details

- **Vector case**: Converts `Vector{Float64}` of length `n` to `Matrix{Float64}(n, 1)`
using `reduce(hcat, data)'` to preserve time-series ordering
- **Matrix case**: Direct conversion to `Matrix{Float64}`

# Examples
- `Matrix{Float64}`: (n_time_points, n_dim)
"""
function _json_to_matrix(blob_field)::Matrix{Float64}
stacked = stack(blob_field; dims=1)
# 1D case: stack() returns Vector → reshape to (n, 1) Matrix
# Multi-D case: stack() returns Matrix → use directly
return stacked isa Vector ? reshape(stacked, :, 1) : Matrix{Float64}(stacked)
end

```julia
# 1D control trajectory (101 time points)
control_data = [5.99, 5.93, ..., -5.99] # Vector{Float64}
control_matrix = _json_array_to_matrix(control_data)
# → Matrix{Float64}(101, 1)
"""
Convert an optional JSON field to Matrix{Float64} or nothing.

# 2D state trajectory (101 time points, 2 dimensions)
state_data = [1.0 2.0; 1.1 2.1; ...] # Matrix{Float64}(101, 2)
state_matrix = _json_array_to_matrix(state_data)
# → Matrix{Float64}(101, 2)
```
# Arguments
- `blob_field`: JSON array field or nothing

# See Also
- Test coverage: `test/suite/serialization/test_export_import.jl`
(testset "JSON stack() behavior investigation")
# Returns
- `Matrix{Float64}` or `nothing`
"""
function _json_array_to_matrix(data)::Matrix{Float64}
if data isa Vector
return Matrix{Float64}(reduce(hcat, data)')
else
return Matrix{Float64}(data)
end
function _json_to_optional_matrix(blob_field)
return isnothing(blob_field) ? nothing : _json_to_matrix(blob_field)
end

"""
Expand Down Expand Up @@ -275,45 +269,17 @@ function CTModels.import_ocp_solution(
json_string = read(filename * ".json", String)
blob = JSON3.read(json_string)

# get state
X = _json_array_to_matrix(stack(blob["state"]; dims=1))

# get control
U = _json_array_to_matrix(stack(blob["control"]; dims=1))

# get costate
P = _json_array_to_matrix(stack(blob["costate"]; dims=1))
# Convert JSON arrays (Vector{Vector}) back to Matrix{Float64}
X = _json_to_matrix(blob["state"])
U = _json_to_matrix(blob["control"])
P = _json_to_matrix(blob["costate"])

# get dual path constraints: convert to matrix
path_constraints_dual = if isnothing(blob["path_constraints_dual"])
nothing
else
_json_array_to_matrix(stack(blob["path_constraints_dual"]; dims=1))
end

# get state constraints (and dual): convert to matrix
state_constraints_lb_dual = if isnothing(blob["state_constraints_lb_dual"])
nothing
else
_json_array_to_matrix(stack(blob["state_constraints_lb_dual"]; dims=1))
end
state_constraints_ub_dual = if isnothing(blob["state_constraints_ub_dual"])
nothing
else
_json_array_to_matrix(stack(blob["state_constraints_ub_dual"]; dims=1))
end

# get control constraints (and dual): convert to matrix
control_constraints_lb_dual = if isnothing(blob["control_constraints_lb_dual"])
nothing
else
_json_array_to_matrix(stack(blob["control_constraints_lb_dual"]; dims=1))
end
control_constraints_ub_dual = if isnothing(blob["control_constraints_ub_dual"])
nothing
else
_json_array_to_matrix(stack(blob["control_constraints_ub_dual"]; dims=1))
end
# Convert optional dual matrices
path_constraints_dual = _json_to_optional_matrix(blob["path_constraints_dual"])
state_constraints_lb_dual = _json_to_optional_matrix(blob["state_constraints_lb_dual"])
state_constraints_ub_dual = _json_to_optional_matrix(blob["state_constraints_ub_dual"])
control_constraints_lb_dual = _json_to_optional_matrix(blob["control_constraints_lb_dual"])
control_constraints_ub_dual = _json_to_optional_matrix(blob["control_constraints_ub_dual"])

# get dual of boundary constraints: no conversion needed
boundary_constraints_dual = blob["boundary_constraints_dual"]
Expand Down Expand Up @@ -364,11 +330,19 @@ function CTModels.import_ocp_solution(

# Add time grid data (format detection handled by helper)
if haskey(blob, "time_grid_state")
# New format: multiple time grids
# Multiple time grids format
data["time_grid_state"] = blob.time_grid_state
data["time_grid_control"] = blob.time_grid_control
data["time_grid_costate"] = blob.time_grid_costate
data["time_grid_dual"] = blob.time_grid_dual
# Support time_grid_costate (backward compatibility: if missing, will use T_state in reconstruction)
if haskey(blob, "time_grid_costate")
data["time_grid_costate"] = blob.time_grid_costate
end
# Support both new (time_grid_path) and legacy (time_grid_dual) keys
if haskey(blob, "time_grid_path")
data["time_grid_path"] = blob.time_grid_path
elseif haskey(blob, "time_grid_dual")
data["time_grid_path"] = blob.time_grid_dual
end
else
# Legacy format: single time grid
data["time_grid"] = blob.time_grid
Expand Down
6 changes: 3 additions & 3 deletions ext/plot.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1590,10 +1590,10 @@ function _map_to_time_grid_component(sym::Symbol)::Symbol
:time => error("Internal error: :time should not be mapped")
:state => :state
:control => :control
:costate => :costate
:costate => :costate # Costate has its own grid
:control_norm => :control # Map control_norm to control for time grid
:path_constraint => :state # Map path_constraint to state for time grid
:dual_path_constraint => :dual # Map dual_path_constraint to dual for time grid
:path_constraint => :path # Path constraints use the path grid
:dual_path_constraint => :path # Path constraint duals use the path grid
_ => error("Internal error: unknown component $sym for time grid mapping")
end
end
Expand Down
1 change: 1 addition & 0 deletions src/OCP/Building/discretization_utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@ See also: `_discretize_function`
function _discretize_dual(dual_func::Union{Function,Nothing}, T, dim::Int=-1)
return isnothing(dual_func) ? nothing : _discretize_function(dual_func, T, dim)
end

Loading
Loading