Clear leveling#

Remove the delays and splits that Project::level wrote into a project, so every task goes back to its unleveled dates.

Leveling stores its result in two task fields: leveling_delay and pauses. Clearing leveling means setting leveling_delay to 0 and pauses to an empty list on every task that carries either, then rescheduling. For how leveling sets these fields, see Leveling resources.

Prerequisites#

  • A solved Project that carries leveling, either from a level call or from loaded data with a non-zero leveling_delay or non-empty pauses.
  • The ids of the tasks that carry a delay or pauses. Take them from your own model, or from the updates of the LevelingResult.

Steps#

  1. Build one TaskUpdate per leveled task. Set leveling_delay to Patch::Value(0.0) and pauses to Patch::Value(Vec::new()), and put all the updates into one SchedulingChanges:

    use plen_core::{Id, Patch, SchedulingChanges, TaskUpdate};
    
    fn clear_leveling(ids: impl IntoIterator<Item = Id>) -> SchedulingChanges {
        let mut changes = SchedulingChanges::default();
        for id in ids {
            let mut update = TaskUpdate::new(id);
            update.leveling_delay = Patch::Value(0.0);
            update.pauses = Patch::Value(Vec::new());
            changes.tasks.update.push(update);
        }
        changes
    }
    

    Leave the dates out of the update. With no date in the same update, the leveling_delay change moves the stored start back by the delay, and the pauses change keeps the start and duration and recomputes the end.

  2. Apply the changes with Project::apply and check the ApplyReport:

    let report = project.apply(&clear_leveling(ids))?;
    if !report.rejected.is_empty() {
        // Resync with project.solve(Some(data), ...) before the next edit.
    }
    
  3. Reschedule with solve(None, ...) and write result.updates back to your model:

    let result = project.solve(None, &SolveOptions::default())?;
    

    The cleared dates become the accepted baseline. The resource contention that leveling resolved shows up again in result.overallocations.

The full example below levels three 2-day tasks that share dev1, then clears the leveling. It uses the default calendar (Monday to Friday, 08:00-12:00 and 13:00-17:00) and starts on Monday, August 10, 2026:

use plen_core::{
    AssignmentData, DateTime, Id, LevelingOptions, Patch, Project, ProjectData, ResourceData,
    SchedulingChanges, SolveOptions, TaskData, TaskUpdate,
};

fn aug(day: u32) -> Result<DateTime, Box<dyn std::error::Error>> {
    Ok(DateTime::from_ymd_hm(2026, 8, day, 8, 0).ok_or("invalid date")?)
}

fn task(id: &str, start: DateTime, duration: f64) -> TaskData {
    let mut task = TaskData::new(id);
    task.start_date = Some(start);
    task.duration = Some(duration);
    task
}

fn booking(task: &str) -> AssignmentData {
    AssignmentData {
        id: None,
        task: task.into(),
        resource: "dev1".into(),
        value: 1.0,
        offset: None,
        duration: None,
    }
}

fn clear_leveling(ids: impl IntoIterator<Item = Id>) -> SchedulingChanges {
    let mut changes = SchedulingChanges::default();
    for id in ids {
        let mut update = TaskUpdate::new(id);
        update.leveling_delay = Patch::Value(0.0);
        update.pauses = Patch::Value(Vec::new());
        changes.tasks.update.push(update);
    }
    changes
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let data = ProjectData {
        tasks: vec![
            task("A", aug(10)?, 2.0),
            task("B", aug(10)?, 2.0),
            task("C", aug(10)?, 2.0),
        ],
        resources: vec![ResourceData {
            id: "dev1".into(),
            capacity: None,
            calendar_id: None,
        }],
        assignments: vec![booking("A"), booking("B"), booking("C")],
        ..Default::default()
    };

    let mut project = Project::default();
    project.solve(Some(data), &SolveOptions::default())?;

    // B is delayed to Wednesday, C to Friday.
    let leveled = project.level(&LevelingOptions::default())?;

    // The tasks that leveling delayed or split.
    let ids: Vec<Id> = leveled
        .schedule
        .updates
        .iter()
        .filter(|u| {
            u.leveling_delay.is_some_and(|d| d != 0.0)
                || u.pauses.as_ref().is_some_and(|p| !p.is_empty())
        })
        .map(|u| u.id.clone())
        .collect();

    let report = project.apply(&clear_leveling(ids))?;
    if !report.rejected.is_empty() {
        return Err("clearing was rejected, resync with solve(Some(data))".into());
    }

    let result = project.solve(None, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    for over in result.overallocations.iter().flatten() {
        for window in &over.windows {
            println!(
                "{}: {} .. {}, peak {}",
                over.resource, window.from, window.to, window.peak
            );
        }
    }
    Ok(())
}

Re-level from scratch instead#

To replace old leveling with a fresh run, skip the steps above and call level again. LevelingOptions.clear_first is true by default, so the run first sets leveling_delay: 0 and pauses: [] on every in-scope task that carries either, then levels the clean plan:

let result = project.level(&LevelingOptions::default())?;

The call diffs its updates against the state it started from. A task whose stale delay was cleared and not replaced still gets an update entry, so writing updates back to your model keeps it in sync.

With a scope, the run clears only in-scope tasks. Set clear_first: false to keep the existing delays and pauses and level on top of them.

Things to know#

  • Patch::Null also works: on leveling_delay it restores the default of 0, and on pauses it clears the list.
  • Only leveling produces pauses. Every pause counts as leveling-owned, whether or not its by is set, so clearing removes all of them.
  • To see what clearing would do before you commit it, pass the same changes to Project::preview. The project stays untouched. See Previewing changes.
  • In a live editor, set changes.baseline_id to the baseline your model shows. apply then fails with ProjectError::StaleBaseline if the project has moved on. See Error handling.
  • If apply rejects an operation, report.needs_resync is true, and solve(None) fails with ProjectError::NeedsResync until you send the full data with solve(Some(data), ...).

Result#

The example prints B and C back on Monday, and the overallocation window on dev1 returns:

B: 2026-08-10 08:00 .. 2026-08-12 08:00
C: 2026-08-10 08:00 .. 2026-08-12 08:00
dev1: 2026-08-10 08:00 .. 2026-08-11 17:00, peak 3