Resource load#

This guide shows how to measure how busy each resource is over a date range. That's the data behind a resource histogram. It covers the profile query, how capacity follows resource calendars, which tasks fill each bucket, and how to get the load for dates you haven't committed yet.

How a load profile works#

Project.LoadProfile reads the solved baseline and cuts the date range into buckets of one hour or one day. For every resource it reports each bucket's Load and Capacity. It's read-only: it never schedules and never changes the project.

The parts fit together like this:

  • A ProfileQuery says what to measure: the range From..To, which Resources, the bucket size (Basis) and whether to list Tasks. new ProfileQuery(from, to) gives you every resource, the default basis and no task lists. Set the other properties with an object initializer.
  • LoadProfileOptions wraps the query and adds an optional BaselineId. A ProfileQuery converts to LoadProfileOptions implicitly, so project.LoadProfile(query) is the short form.
  • The result is a LoadProfile: the Basis it used and one ResourceLoad per resource. Each ResourceLoad has the Resource id, its CapacityMode and a list of LoadBucket records with Start, Load, Capacity and Tasks.

These rules decide what ends up in the buckets:

  • Range. A bucket is included when its start lies in [From, To). A day bucket starts at midnight, so a From of Monday 12:00 skips Monday and starts on Tuesday. To is exclusive.
  • Dense output. Every bucket in the range appears, including empty ones with load 0. That's why the range is capped at 100 000 buckets per resource, about 11 years at hour basis.
  • Basis. By default the basis follows config.DurationUnit: Basis.Day for days, Basis.Hour for hours and minutes. Set Basis to override it.
  • Load units. Load is in assignment Value units, where 1 is 100 %. Nothing is converted to hours. An assignment that works in a bucket books its Value there, so two full-time tasks on the same day give a load of 2.0.
  • Same load as leveling. The profile uses the same sweep as the leveler and the Overallocations in ScheduleResult: only real work segments, each assignment on its own calendar, cut to its slice. A bucket overallocates when Load > Capacity.
  • Order. Resources come back in input order. Task ids in a bucket do too.

The profile always describes the accepted baseline, so the project has to be solved and clean.

Failure Exception
Project not solved yet, or edited with Apply since the last solve ProjectException, Code = ProjectError.NoBaseline
BaselineId doesn't name the current baseline ProjectException, Code = ProjectError.StaleBaseline
To <= From, or more than 100 000 buckets InputException, Path = "to"
Unknown id in Resources InputException, Path = "resources"

See Error handling for the exception types and the LoadProfile reference for every property.

Querying a profile#

Solve the project, build a ProfileQuery and pass it to LoadProfile.

using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

static TaskData Task(string id, Moment start, double duration) =>
    new(id) { StartDate = start, Duration = duration };

static AssignmentData Assign(string task, string resource, double value) =>
    new() { Id = $"{task}:{resource}", Task = task, Resource = resource, Value = value };

// 2026-08-10 is a Monday.
var data = new ProjectData
{
    Tasks =
    [
        Task("a", At(10, 8), 2.0),
        Task("b", At(10, 8), 1.0),
        Task("c", At(11, 8), 1.0),
        Task("d", At(12, 8), 1.0),
    ],
    Resources = [new ResourceData { Id = "dev" }],
    Assignments =
    [
        Assign("a", "dev", 1.0),
        Assign("b", "dev", 1.0),
        Assign("c", "dev", 0.5),
        Assign("d", "dev", 1.0),
    ],
};

var project = new Project();
project.Solve(data, new SolveOptions());

// Sunday 9 up to Friday 14; `To` is exclusive.
var profile = project.LoadProfile(new ProfileQuery(At(9, 0), At(14, 0)));

Console.WriteLine($"basis: {profile.Basis}");
foreach (var row in profile.Resources)
{
    foreach (var bucket in row.Buckets)
        Console.WriteLine($"{row.Resource} {bucket.Start}: load {bucket.Load} of {bucket.Capacity}");
}

Output:

basis: Day
dev 2026-08-09 00:00: load 0 of 1
dev 2026-08-10 00:00: load 2 of 1
dev 2026-08-11 00:00: load 1.5 of 1
dev 2026-08-12 00:00: load 1 of 1
dev 2026-08-13 00:00: load 0 of 1

The project uses day units, so the basis is Day and you get five buckets, Sunday to Thursday. Monday carries a and b, and Tuesday carries a plus c at half time. Capacity is 1.0 in every bucket: dev has no capacity of its own, so it takes config.DefaultResourceCapacity.

Narrowing the query#

Set Resources to measure only some resources, and Basis to change the bucket size.

using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

static TaskData Task(string id, Moment start, double duration) =>
    new(id) { StartDate = start, Duration = duration };

var data = new ProjectData
{
    Tasks = [Task("a", At(10, 8), 2.0), Task("b", At(10, 8), 1.0)],
    Resources = [new ResourceData { Id = "dev" }, new ResourceData { Id = "qa" }],
    Assignments =
    [
        new AssignmentData { Task = "a", Resource = "dev", Value = 1.0 },
        new AssignmentData { Task = "b", Resource = "dev", Value = 1.0 },
    ],
};

var project = new Project();
project.Solve(data, new SolveOptions());

// Monday 07:00 to 10:00, hour buckets, `dev` only.
var query = new ProfileQuery(At(10, 7), At(10, 10))
{
    Resources = ["dev"],
    Basis = Basis.Hour,
};
var profile = project.LoadProfile(query);

foreach (var row in profile.Resources)
    Console.WriteLine($"{row.Resource}: [{string.Join(", ", row.Buckets.Select(b => b.Load))}]");

Output:

dev: [0, 2, 2]

Only dev comes back, with three hour buckets. At 07:00 work hasn't started yet, and the 08:00 and 09:00 buckets carry both tasks. An id in Resources that isn't in the project throws InputException with Path = "resources".

Pinning the profile to a baseline#

A UI often fetches the profile some time after the solve. To make sure the profile matches the dates on screen, pass the token from the solve result as LoadProfileOptions.BaselineId. If the project was solved again in the meantime, the call throws ProjectException with ProjectError.StaleBaseline instead of returning load for other dates.

using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks = [new TaskData("a") { StartDate = At(10, 8), Duration = 2.0 }],
    Resources = [new ResourceData { Id = "dev" }],
    Assignments = [new AssignmentData { Task = "a", Resource = "dev", Value = 1.0 }],
};

var project = new Project();
var solved = project.Solve(data, new SolveOptions());

var options = new LoadProfileOptions(new ProfileQuery(At(10, 0), At(15, 0)))
{
    BaselineId = solved.BaselineId,
};
var profile = project.LoadProfile(options);
Console.WriteLine($"{profile.Resources.Count} resource(s) measured");

Take the token from the latest solve result. Tokens are opaque, and the same data solved twice can get a different one, so don't store them or compare them across runs.

Capacity modes#

Capacity is the resource's Capacity, or config.DefaultResourceCapacity (1.0) when it's null. The CapacityMode of each ResourceLoad tells you how that capacity is spread over the buckets:

  • CapacityMode.Calendar: the resource has its own calendar, config.CalendarMode is CalendarMode.Resource (the default) and config.UseCalendars is true. Buckets with working time on that calendar get the full capacity. All others get 0.
  • CapacityMode.Flat: every bucket gets the full capacity, weekends and nights included. This applies when the resource has no calendar, when CalendarMode is a task mode, or when UseCalendars is false.

In calendar mode an assignment always works on the resource's calendar, or on the overlap of the task's and the resource's calendars. A bucket with capacity 0 never gets load. A bucket overallocates in the profile exactly when leveling sees it as overallocated.

using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

// Days run Sunday..Saturday; hours are whole hours.
static CalendarData Calendar(string id, bool[] days, int from, int to) => new()
{
    Id = id,
    Worktime = new Worktime
    {
        Hours = [new TimeRange((ushort)(from * 60), (ushort)(to * 60))],
        Days = days,
    },
};

bool[] monFri = [false, true, true, true, true, true, false];
bool[] tueSat = [false, false, true, true, true, true, true];

var data = new ProjectData
{
    // Tuesday, two days.
    Tasks = [new TaskData("a") { StartDate = At(11, 8), Duration = 2.0 }],
    Calendars =
    [
        Calendar("global", monFri, 8, 16),
        Calendar("tue_sat", tueSat, 8, 16),
        Calendar("mornings", monFri, 8, 12),
    ],
    Resources =
    [
        new ResourceData { Id = "office" },
        new ResourceData { Id = "shop", CalendarId = "tue_sat" },
        new ResourceData { Id = "part_time", CalendarId = "mornings" },
    ],
    Assignments = [new AssignmentData { Task = "a", Resource = "shop", Value = 1.0 }],
};

var project = new Project();
project.Solve(data, new SolveOptions());

// One week in day buckets, Sunday 9 to Saturday 15.
var week = project.LoadProfile(new ProfileQuery(At(9, 0), At(16, 0)));
foreach (var row in week.Resources)
{
    var capacity = string.Join(", ", row.Buckets.Select(b => b.Capacity));
    var load = string.Join(", ", row.Buckets.Select(b => b.Load));
    Console.WriteLine($"{row.Resource} ({row.CapacityMode}): capacity [{capacity}], load [{load}]");
}

// Tuesday 06:00 to 18:00 in hour buckets, for the part-time resource.
var day = new ProfileQuery(At(11, 6), At(11, 18))
{
    Resources = ["part_time"],
    Basis = Basis.Hour,
};
var hours = project.LoadProfile(day);
Console.WriteLine($"part_time hourly capacity: [{string.Join(", ", hours.Resources[0].Buckets.Select(b => b.Capacity))}]");

Output:

office (Flat): capacity [1, 1, 1, 1, 1, 1, 1], load [0, 0, 0, 0, 0, 0, 0]
shop (Calendar): capacity [0, 0, 1, 1, 1, 1, 1], load [0, 0, 1, 1, 0, 0, 0]
part_time (Calendar): capacity [0, 1, 1, 1, 1, 1, 0], load [0, 0, 0, 0, 0, 0, 0]
part_time hourly capacity: [0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0]

What the week shows:

  • office has no calendar, so it's Flat, with capacity 1.0 on all seven days, Sunday and Saturday included.
  • shop is Calendar. Its capacity is 0 on Sunday and Monday and 1.0 from Tuesday to Saturday. Task a runs Tuesday and Wednesday, so those two buckets have load 1.0.
  • At hour basis, part_time has capacity only in the four hour buckets between 08:00 and 12:00. The other eight hours are 0.

Set data.Config.CalendarMode = CalendarMode.Task (or UseCalendars = false) and all three resources become Flat, with capacity 1.0 in every bucket. See Working time and calendars for calendar setup and the LoadProfile reference for CapacityMode.

Task lists per bucket#

A histogram usually needs to say who is overloading a resource. Set ProfileQuery.Tasks to fill LoadBucket.Tasks:

ProfileTasks LoadBucket.Tasks
None (default) null in every bucket
Over the task ids in buckets where Load > Capacity, an empty list elsewhere
All the ids of every task booked in the bucket
using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

static TaskData Task(string id, Moment start, double duration) =>
    new(id) { StartDate = start, Duration = duration };

static AssignmentData Assign(string task, double value) =>
    new() { Task = task, Resource = "dev", Value = value };

var data = new ProjectData
{
    Tasks =
    [
        Task("a", At(10, 8), 2.0),
        Task("b", At(10, 8), 1.0),
        Task("c", At(11, 8), 1.0),
        Task("d", At(12, 8), 1.0),
    ],
    Resources = [new ResourceData { Id = "dev" }],
    Assignments = [Assign("a", 1.0), Assign("b", 1.0), Assign("c", 0.5), Assign("d", 1.0)],
};

var project = new Project();
project.Solve(data, new SolveOptions());

// Monday 10 to Thursday 13.
var query = new ProfileQuery(At(10, 0), At(14, 0)) { Tasks = ProfileTasks.Over };
var profile = project.LoadProfile(query);

foreach (var bucket in profile.Resources[0].Buckets)
{
    var tasks = bucket.Tasks is null ? "null" : $"[{string.Join(", ", bucket.Tasks)}]";
    Console.WriteLine($"{bucket.Start}: load {bucket.Load}, tasks {tasks}");
}

Output:

2026-08-10 00:00: load 2, tasks [a, b]
2026-08-11 00:00: load 1.5, tasks [a, c]
2026-08-12 00:00: load 1, tasks []
2026-08-13 00:00: load 0, tasks []

With Over, Monday lists a and b, and Tuesday lists a and c. Wednesday and Thursday aren't overloaded, so their lists are empty. Switch to ProfileTasks.All and Wednesday lists d too, because All reports every booked task regardless of capacity.

If you need only the overloaded periods and not the full histogram, skip the profile. Every ScheduleResult of a project with assignments carries Overallocations: per-resource windows with Peak, Overload and the contending Tasks. See Leveling resources for how to remove the overload.

Load of hypothetical dates#

LoadProfile only reads the baseline. To see the load an edit would cause, set PreviewOptions.Profile to a ProfileQuery and call Project.Preview. The result's Profile is computed on the previewed dates, and the project stays as it was.

using Plen;

static Moment At(int day, int hour) =>
    Moment.FromYmdHm(2026, 8, day, hour, 0) ?? throw new InvalidOperationException("invalid date");

static TaskData Task(string id, Moment start, double duration) =>
    new(id) { StartDate = start, Duration = duration };

static AssignmentData Assign(string task, string resource, double value) =>
    new() { Id = $"{task}:{resource}", Task = task, Resource = resource, Value = value };

var data = new ProjectData
{
    Tasks =
    [
        Task("a", At(10, 8), 2.0),
        Task("b", At(10, 8), 1.0),
        Task("c", At(11, 8), 1.0),
    ],
    Resources = [new ResourceData { Id = "dev" }, new ResourceData { Id = "qa" }],
    Assignments =
    [
        Assign("a", "dev", 1.0),
        Assign("b", "dev", 1.0),
        Assign("c", "dev", 0.5),
    ],
};

var project = new Project();
project.Solve(data, new SolveOptions());

// What if `b` moves from `dev` to `qa`?
var changes = new SchedulingChanges();
changes.Assignments.Update.Add(new AssignmentUpdate("b:dev") { Resource = Patch<Id>.Of("qa") });

var options = new PreviewOptions { Profile = new ProfileQuery(At(10, 0), At(12, 0)) };
var preview = project.Preview(changes, options);

if (preview.Profile is { } profile)
{
    foreach (var row in profile.Resources)
        Console.WriteLine($"previewed {row.Resource}: [{string.Join(", ", row.Buckets.Select(b => b.Load))}]");
}

// The baseline still has both tasks on `dev`.
var baseline = project.LoadProfile(new ProfileQuery(At(10, 0), At(12, 0)));
Console.WriteLine($"baseline dev: [{string.Join(", ", baseline.Resources[0].Buckets.Select(b => b.Load))}]");

Output:

previewed dev: [1, 1.5]
previewed qa: [1, 0]
baseline dev: [2, 1.5]

In the preview, dev drops to 1.0 on Monday and keeps 1.5 on Tuesday, and qa gets 1.0 on Monday. The baseline profile still shows 2.0 and 1.5 for dev. Without PreviewOptions.Profile, preview.Profile is null.

PreviewOptions.Profile takes a bare ProfileQuery, not LoadProfileOptions, so it has no BaselineId of its own. To guard a preview against a stale baseline, set PreviewOptions.BaselineId. An invalid query fails the whole preview with InputException.

To profile a multi-step edit or a leveling run, call Clone() on the project, edit or Level the clone, and call LoadProfile on it. The clone is cheap, and the original doesn't change. See Previewing changes for both approaches.