Project data#
This guide shows how to describe a plan for Planning Engine: tasks, links, ids, dates, durations, and the config switches that change how the plan is scheduled. It also lists what strict validation rejects, so you can build valid input the first time.
How project data works#
A plan goes in as one ProjectData object. It has six properties: Config, Calendars, Tasks, Links, Resources and Assignments. Every list starts empty, and Config starts as new SchedulingConfig() with its defaults.
ProjectData is an import format, not stored state. Project.Solve(data, …), Engine.Schedule and Project.Analyze validate it and build the engine's own normalized model from it. Solve deep-copies your object, so later changes to it don't leak into the project. The engine never hands the snapshot back. Results report changes as sparse Updates, and you write them back into your own model.
The records are mutable classes with { get; set; } properties:
new TaskData(id)gives a task with every default filled in. Set the properties you need in an object initializer.LinkData,CalendarData,ResourceDataandAssignmentDatahaverequiredmembers. Create them with object initializers, for examplenew LinkData { Id = "l1", Source = "a", Target = "b" }.ProjectDataandSchedulingConfighave parameterless constructors, so you set only what differs from the defaults.- Every record has
DeepClone()for a copy that shares no lists with the original.
Property names are the PascalCase forms of the wire field names (StartDate, Duration, ConstraintType, AutoScheduling). TaskType and LinkType are called type on the wire.
A few rules hold everywhere:
Idis a number or a string and compares like a JavaScript object key, so task1and task"1"are the same task.Momentis a naive local date-time at minute precision. There's no time zone, no DST and no "today".DurationandLagare working time, measured on calendars.LevelingDelayandPausesare elapsed wall-clock time.- Strict validation (the default) rejects ambiguous input with an
InputExceptionthat names the field, instead of guessing.Validation.Repairfixes what it can and reports each fix.
The core package doesn't read JSON. The separate Plen.Wire package decodes JSON with snake_case field names; see Exchange JSON with a client.
For every property and its default, see ProjectData, TaskData and SchedulingConfig.
Describing a project#
Tasks#
A leaf task needs two of StartDate, EndDate and Duration. The engine derives the third. If you send all three, they must agree on the task's calendar.
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
// A summary with no dates: its span comes from its children.
new TaskData("phase") { TaskType = TaskType.Summary },
new TaskData("design") { Parent = "phase", StartDate = monday, Duration = 3 },
new TaskData("build") { Parent = "phase", StartDate = monday, Duration = 5 },
// A milestone needs only one date.
new TaskData("release") { TaskType = TaskType.Milestone, StartDate = monday },
],
Links =
[
new LinkData { Id = "l1", Source = "design", Target = "build" },
new LinkData { Id = "l2", Source = "build", Target = "release" },
],
};
var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
if (update.StartDate is { } start && update.EndDate is { } end)
Console.WriteLine($"{update.Id}: {start} .. {end}");
}
The links push build after design and release after build, and only those two tasks appear in Updates. The summary phase has no dates of its own, so its span comes from design and build.
Each task type has its own date rule:
TaskType.Task(the default) is a leaf: two of start, end and duration.TaskType.Summarytakes both dates or neither. Its span is derived, so a summary withStartDate+Durationis rejected. A summary with no dates needs at least one dated child.TaskType.MilestoneneedsStartDateorEndDate. Its duration is 0.
Parent links a task to its summary. null, "" and a value equal to Config.RootId (default 0) all mean "no parent". A parent that doesn't exist, or a Parent chain that loops back on itself, is a validation error.
The rest of TaskData covers constraints (ConstraintType, ConstraintDate), calendars (CalendarId), and leveling inputs (Priority, LevelingDelay, Pauses). See Links, constraints and summaries, Working time and calendars and Leveling resources.
Ids and dates#
string, int and double convert to Id implicitly, so you can write "a" or 1 wherever an id is expected. Ids compare by their JavaScript key, Id.Key(), so a number and its string form are the same id.
using Plen;
Id numeric = 1;
Id text = "1";
Console.WriteLine(numeric == text); // True
// -0 and 0 share the key "0".
Console.WriteLine(Id.Num(-0.0) == Id.Str("0")); // True
Console.WriteLine(numeric); // prints the key: 1
This matters when you mix sources. Task 1 from one system and task "1" from another collide as duplicates. Numeric ids are double, so integers past 2^53 lose precision, just as they do in JavaScript.
Moment is a local date-time with minute precision, stored as minutes since 1970-01-01 00:00. Build one with Moment.FromYmdHm, which returns null for an impossible date, or convert a DateTime with Moment.FromDateTime (seconds are dropped):
using Plen;
var a = Moment.FromYmdHm(2026, 3, 2, 9, 30) ?? throw new InvalidOperationException("invalid date");
var b = Moment.FromDateTime(new DateTime(2026, 3, 2, 9, 30, 45)); // 09:30:45 becomes 09:30
Console.WriteLine(a == b); // True
Console.WriteLine(a); // 2026-03-02 09:30
DateTime back = a.ToDateTime(); // DateTimeKind.Unspecified
Console.WriteLine(back);
Moment carries no time zone. FromDateTime reads the date and time fields as they are and ignores DateTime.Kind, so convert a UTC value to the plan's local time first. The engine never reads the clock, so an entirely undated project is rejected instead of being placed at "today". ToDateTime() throws ArgumentOutOfRangeException for a moment outside years 1-9999. Records, ids and dates covers both types in more depth.
Durations and units#
Duration is working time in a unit: TimeUnit.Minute, TimeUnit.Hour or TimeUnit.Day. Config.DurationUnit sets the unit for the whole project (default Day). A task's own DurationUnit overrides it for that task.
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Config = new SchedulingConfig { DurationUnit = TimeUnit.Hour },
Tasks =
[
// 12 working hours: the default calendar works 08-12 and 13-17.
new TaskData("review") { StartDate = monday, Duration = 12 },
// This task counts in minutes instead of the project's hours.
new TaskData("sync") { StartDate = monday, Duration = 90, DurationUnit = TimeUnit.Minute },
],
};
var result = Engine.Schedule(data, new SolveOptions());
Console.WriteLine($"{result.Updates.Count} updates");
Durations are double, so 1.5 days or 0.25 hours are valid. A negative or non-finite duration is rejected. Week, month and year units aren't supported: TimeUnit has no such members.
Day units count whole working days. From a midnight anchor, Tuesday 00:00 + 4 days ends at Saturday 00:00, which is the close of Friday. From a non-midnight anchor, days are counted to the same time of day. With a sparse calendar, that can skip working hours: on a Mon-Wed 9:00-13:00 calendar, a 3-day task from Monday 9:00 ends the next Monday 9:00. If that matters, use hour units.
Two task properties use elapsed time instead of working time: LevelingDelay and Pauses. Both are in the task's duration unit, where a day is 24 hours, and both are rounded to whole minutes on use. The leveler writes them. Read Leveling resources before you set them by hand.
Links#
A LinkData connects a Source task to a Target task. LinkType is one of LinkType.Fs (finish-to-start, the default), Ss, Ff or Sf. Lag is working time added along the link, in Config.DurationUnit unless the link sets its own LagUnit.
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
TaskData Make(string id) => new(id) { StartDate = monday, Duration = 2 };
var data = new ProjectData
{
Tasks = [Make("a"), Make("b"), Make("c")],
Links =
[
// b starts 1 working day after a starts.
new LinkData { Id = "ss", Source = "a", Target = "b", LinkType = LinkType.Ss, Lag = 1 },
// c finishes 4 working hours after b finishes.
new LinkData
{
Id = "ff", Source = "b", Target = "c",
LinkType = LinkType.Ff, Lag = 4, LagUnit = TimeUnit.Hour,
},
// Points at a task that doesn't exist: dropped, not an error.
new LinkData { Id = "stray", Source = "c", Target = "missing" },
],
};
var result = Engine.Schedule(data, new SolveOptions());
foreach (var note in result.Notes)
{
if (note is Note.DroppedLink dropped)
Console.WriteLine($"link {dropped.Link} dropped: {dropped.Reason}");
}
Leave LagUnit as null to measure lag in the project unit.
Some links can't apply. The engine drops them and reports a Note.DroppedLink with the reason instead of throwing:
- the source or target task doesn't exist;
- the source or target task is unscheduled;
- the link joins a summary to one of its own descendants, and
Config.DescendantLinksisfalse.
A duplicate link id or a non-finite Lag is a validation error. The engine keeps a link from a task to itself and reports it as a one-task cycle. How each link type moves its target, and how links attach to summaries, is in Links, constraints and summaries.
Fixed, unscheduled and completed tasks#
Three task states keep a task where it is:
AutoScheduling = falsemakes the task fixed. The planner never moves it, but links from it still push its successors.Unscheduled = truetakes the task out of the plan. It isn't scheduled or reported, and it doesn't need dates. Its descendants go with it. Links that touch it are dropped with a note.- With
Config.UseProgresson, a task withProgress == 1is completed and stays put.
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Config = new SchedulingConfig { UseProgress = true },
Tasks =
[
// Completed, because UseProgress is on.
new TaskData("done") { StartDate = monday, Duration = 2, Progress = 1 },
// Fixed.
new TaskData("pinned") { StartDate = monday, Duration = 1, AutoScheduling = false },
// No dates needed.
new TaskData("later") { Unscheduled = true },
],
};
var result = Engine.Schedule(data, new SolveOptions());
foreach (var note in result.Notes)
{
if (note is Note.DroppedTask dropped)
Console.WriteLine($"task {dropped.Task}: {dropped.Reason}"); // task later: unscheduled
}
Progress must be between 0 and 1. A completed task also stays in place inside a summary that moves as a block. In analytics, completed tasks have zero slack and are never critical.
Scheduling config#
SchedulingConfig holds the project-wide switches. Create it with new and set only what you need:
using Plen;
var kickoff = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var config = new SchedulingConfig
{
ApplyConstraints = true, // constraints act as hard bounds
GapBehavior = GapBehavior.Compress, // pull tasks to their earliest
DurationUnit = TimeUnit.Hour,
ProjectStart = kickoff, // nothing starts before this
CalendarMode = CalendarMode.Task, // task calendars only
RootId = -1, // data with a virtual root of -1
};
var data = new ProjectData
{
Config = config,
Tasks =
[
// Parent equals RootId: no parent.
new TaskData(1) { Parent = -1, StartDate = kickoff, Duration = 4 },
],
};
Engine.Schedule(data, new SolveOptions());
The switches you'll change most often:
| Property | Default | Effect |
|---|---|---|
ApplyConstraints |
false |
Turns on the eight constraint types. Off, constraints are ignored, including Mso pins |
ScheduleFromEnd |
false |
Backward (ALAP) planning. Level refuses such projects |
GapBehavior |
Preserve |
Preserve keeps tasks at their own dates when links allow; Compress pulls every task to its earliest position |
MoveProjects |
true |
Summaries move as a block; false schedules their leaves one by one |
UseProgress |
false |
Tasks with Progress == 1 are completed and fixed |
ProjectStart |
null |
Lower bound for the plan, and the date source for undated tasks in repair mode |
ProjectEnd |
null |
Upper bound, used by backward projects |
DurationUnit |
Day |
Unit of Duration and Lag |
UseCalendars |
true |
false makes every calendar flat 24/7 time |
CalendarMode |
Resource |
Which calendars place the work; Task / TaskInherited use task calendars only |
MaxIterations |
8 |
Planner pass limit; must be at least 1 |
RootId |
Id.Num(0.0) |
The Parent value that means "no parent" |
ProjectConstraint, GroupMode, DescendantLinks, StrictCalendar and DefaultResourceCapacity cover rarer cases. See SchedulingConfig for all of them.
RootId exists for data whose virtual root isn't 0. The engine strips it on import, so stored tasks never point at the root. With a non-zero RootId, a task with id 0 is an ordinary parent.
Left at its default, UseCalendars is true and Planning Engine schedules on calendars. Set it explicitly when your data source has its own working-time switch.
Validation rules#
The default Validation.Strict mode rejects ambiguous or inconsistent input. The call throws an InputException whose Path names the field, and a live Project keeps its previous state and cached result.
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData("ok") { StartDate = monday, Duration = 1 },
new TaskData("broken") { Duration = 2 }, // only one of start / end / duration
],
};
try
{
Engine.Schedule(data, new SolveOptions());
Console.WriteLine("scheduled");
}
catch (InputException e)
{
Console.WriteLine($"{e.Path ?? "-"}: {e.Message}");
}
Path uses the wire spelling of the field (tasks[1].start_date), so it matches the JSON your client sent. Strict import rejects:
| Input | Error path |
|---|---|
| Duplicate task, link, resource or calendar id | tasks[i].id, links[i].id, resources[i].id, calendars[i] |
| A leaf with fewer than two of start, end and duration | tasks[i].start_date |
| Start, end and duration that don't agree | tasks[i].duration |
EndDate before StartDate |
tasks[i].end_date |
| A summary with only one date, or start + duration | tasks[i].start_date |
| A summary with no dates and no dated children | tasks[i] |
| A milestone with no date | tasks[i].start_date |
| A negative or non-finite duration | tasks[i].duration |
A dated constraint without ConstraintDate |
tasks[i].constraint_date |
An unknown Parent, or a cycle in the hierarchy |
tasks[i].parent |
Progress outside 0..1, Priority above 1000, negative LevelingDelay |
tasks[i].progress, .priority, .leveling_delay |
Unsorted, overlapping or invalid Pauses |
tasks[i].pauses[j] |
An unknown task CalendarId |
tasks[i].calendar_id |
| A calendar with no working time | calendars[i] |
Worktime.Days without exactly 7 entries |
calendars[i].worktime.days |
A non-finite link Lag |
links[i].lag |
MaxIterations = 0 |
config.max_iterations |
A negative or non-finite DefaultResourceCapacity |
config.default_resource_capacity |
Resource and assignment rules (capacity, Value, slices, calendars with no shared working time) are in Resources and assignments.
Some things aren't errors. Links that can't apply become Note.DroppedLink, unscheduled tasks become Note.DroppedTask, and dependency cycles are reported in Cycles.
When the data comes from a UI that saves messy records, switch to repair mode instead of fixing the input yourself:
using Plen;
var monday = Moment.FromYmdHm(2026, 3, 2, 8, 0) ?? throw new InvalidOperationException("invalid date");
var project = new Project(new ProjectOptions { Validation = Validation.Repair });
var data = new ProjectData
{
Tasks =
[
new TaskData("a") { StartDate = monday, Duration = 1 },
// Same id again: in repair mode the last record wins.
new TaskData("a") { StartDate = monday, Duration = 3 },
],
};
var result = project.Solve(data, new SolveOptions());
foreach (var note in result.Notes)
{
if (note is Note.Repaired repaired)
Console.WriteLine($"{repaired.Path}: {repaired.Message}");
}
Repair deduplicates ids (the last record wins and keeps the first position), derives missing dates and durations, lets dates win in an inconsistent triple, and clears dated constraints without a date. Each fix shows up as a Note.Repaired with its path. A task with no dates gets Config.ProjectStart, or the earliest date of another task. When neither exists, as in a fully undated project, repair fails at tasks[i].start_date. See Accept messy input and Error handling.