Commit 9ca4dae2 authored by Christos Tranoris's avatar Christos Tranoris
Browse files

Duty-cycle the RadioEngineer's OPERATION estimate

parent fbafba67
Loading
Loading
Loading
Loading
+77 −0
Changes for docs/20260718_2236_duty-cycle-engineer-operation-estimate.md: 77 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Duty-cycle the RadioEngineer's OPERATION estimate

**Date:** 2026-07-18

## The problem

`ResourceEstimator.operation()` sized the OPERATION phase as `rate x windowHours`, where the window is
the `org.etsi.osl.operationDurationSeconds` header OSOM sends on a planning request. That is correct for
a VM or a radio node — they really are powered for every hour of the engagement.

It is wrong for `RadioEngineer`, which is a person. A 30-day engagement billed
`720 h x €45 = €32,400`, as if the engineer were on duty around the clock. `RadioEngineer` already
declared a `shiftHours` characteristic (default 8) and `resourceLogic` read it, but `estimateProfile()`
ignored it entirely — so the shift length a user configured had no effect on the plan.

The same engagement now costs `30 x 8 x €45 = €10,800`, and a request asking for a 12h shift is billed
proportionally more.

## The model

`ResourceEstimator.Profile` gains a ninth component, `operationHoursPerDay`. `operation()` scales energy
and cost by that duty cycle:

```java
double dutyCycle = clamp(p.operationHoursPerDay(), 0.0, 24.0) / 24.0;
double activeHours = (operationDurationSeconds / 3600.0) * dutyCycle;
```

A secondary 8-arg constructor defaults it to `24.0` (always-on), so the nine other specs' profiles
compile unchanged and their figures do not move — locked in by `theEightArgConstructor_meansAlwaysOn`.
The clamp is deliberate: this number now originates in a request characteristic, and neither a negative
day (a cost rebate) nor a 30-hour one should ever reach the wire.

## Two decisions worth recording

**The reported duration still echoes the window.** Only energy and cost are duty-cycled. A 30-day
engagement of an 8-hour shift reports `durationSeconds = 2,592,000` and `cost = 10,800`. The engagement
genuinely is 30 days in wall-clock terms, and `durationSeconds == the window OSOM asked us to size
against` is an invariant every spec honours; making it mean "hours worked" for one spec would have made
the field's meaning spec-dependent.

**The estimate reads the request, never the instance field.** `ResourceSpecCatalogConfig` builds one
`RadioEngineer` shared by every request, and the OPERATION callback fires on a background thread ~2 s
after `resourceLogic` ran. Deriving the profile from `this.shiftHours` would have raced across concurrent
planning requests — one service order's shift length silently pricing another's. So `ExampleResourceSpec`
gains an overridable

```java
public ResourceEstimator.Profile estimateProfile(ResourceUpdate requested) {
    return estimateProfile();   // default: most specs cost the same whatever was requested
}
```

and `RadioEngineer` overrides it, parsing `shiftHours` off the request and falling back to 8 when it is
absent, unparseable, or outside `(0, 24]`. `ResourceRepoService` threads the request object through all
four estimate call sites (creation, operation on both the create and update planning paths, teardown).
`resourceLogicMutation_doesNotLeakIntoALaterEstimate` is the regression guard.

Every calendar day in the window is a shift day — no weekend model. This is an example controller; the
arithmetic staying legible is the point.

## Also fixed

`ResourceEstimatorTest.radioEngineer_reportsMeasuredZeroEnergy_withRealLabourCost` asserted a creation
cost of `50.0` against a profile that has always declared `100.0`. That test was failing before this
change; the assertion now matches the profile.

## Scope

- `ResourceEstimator` — `Profile.operationHoursPerDay` + duty cycle in `operation()`
- `ExampleResourceSpec` — the `estimateProfile(ResourceUpdate)` hook
- `RadioEngineer` — `DEFAULT_SHIFT_HOURS`, the override, `shiftHoursFrom()`
- `ResourceRepoService` — request threaded into four estimate call sites; `profileOf` now takes the request
- Tests — 4 new in `ResourceEstimatorTest`, new `RadioEngineerTest` (5). 67 pass.

No change to headers, queues, or the callback payload. Only `org.etsi.osl.estimate.operation.cost`
changes, and only for `RadioEngineer`.
+1 −0
Changes for docs/index.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@

| Doc | Description |
|---|---|
| [20260718_2236_duty-cycle-engineer-operation-estimate.md](20260718_2236_duty-cycle-engineer-operation-estimate.md) | Bill `RadioEngineer` for hours *worked* rather than hours elapsed. A 30-day engagement charged `720 h x €45` as if the engineer were on duty around the clock; `ResourceEstimator.Profile` gains an `operationHoursPerDay` duty cycle (defaulting to 24 via a secondary constructor, so no other spec's figures move) and the same engagement now costs `30 x 8 x €45`. The reported `durationSeconds` deliberately keeps echoing OSOM's window — only energy and cost are duty-cycled. The requested `shiftHours` reaches the estimate through a new overridable `estimateProfile(ResourceUpdate)` rather than the singleton's mutable field, which the background OPERATION callback would have raced on. |
| [20260716_1615_more-example-resource-specs.md](20260716_1615_more-example-resource-specs.md) | Grow the demo catalogue from 2 specs to 10 — `VirtualMachine`, `VPNAccess`, `KubernetesCluster`, `5GCore`, `RadioNode`, `CPE`, `MobilePhone`, `RadioEngineer` — each with its own characteristics and (invented) estimate profile spanning three orders of magnitude. The four tangible ones register as `PhysicalResourceSpecification`; `RadioEngineer` reports measured-zero energy against a labour cost rate, exercising the estimate contract's null-is-not-zero rule. Replaces the four hard-coded lists that had to stay in sync with one `ExampleResourceSpec` base plus a fail-fast `ResourceSpecRegistry`, so adding a spec is one class and one line. Fixes `CatalogClient` reply strictness (physical specs would have stack-traced at every startup) and a `Math.abs(hash)` idiom that can go negative. Adds the project's first tests (58). |
| [20260716_1430_report-lifecycle-estimates.md](20260716_1430_report-lifecycle-estimates.md) | Report simulated CREATION / OPERATION / TEARDOWN estimates (seconds / Watt-hours / dimensionless units) back to OSOM as JMS headers on the planning callback, one phase per callback. New stateless `ResourceEstimator` holds the per-spec cost model (invented numbers — it demonstrates the contract, it models nothing real); the OPERATION phase is sized by the `org.etsi.osl.operationDurationSeconds` window OSOM sends, and is omitted entirely when that header is absent rather than defaulted locally. Also adds `UPDATE/…/PLANNING` and `DELETE/…/PLANNING` routes, so OSOM's day-2 dry-runs stop landing on the production queues — `UNDER SIMULATION DELETE` now actually appears in the log. |
| [20260707_0044_dedup-resourcereposervice.md](20260707_0044_dedup-resourcereposervice.md) | Refactor `ResourceRepoService`: extract `resourceIdFromHeaders` + `applyResourceLogic` helpers and header-key constants to remove three duplicated boilerplate blocks (no behavior change). |
+16 −0
Changes for src/main/java/org/etsi/osl/example/gc/api/ExampleResourceSpec.java: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -66,6 +66,22 @@ public abstract class ExampleResourceSpec extends DomainModelDefinition
    /** This spec's invented duration/energy/cost constants; never null. */
    public abstract ResourceEstimator.Profile estimateProfile();

    /**
     * This spec's constants for one specific request, given the characteristics OSOM asked for. Defaults
     * to the fixed {@link #estimateProfile()}: most specs cost the same whatever was requested.
     *
     * <p>Overriding this is how a spec makes its estimate depend on the request (see
     * {@link RadioEngineer}, whose cost scales with the requested shift length), and it is the only safe
     * way to do so. Specs are singletons shared across concurrent requests, and the OPERATION estimate is
     * built on a background thread well after {@link #resourceLogic} ran — so a profile must never be
     * derived from instance fields that {@code resourceLogic} mutated. Read the request, not the field.
     *
     * @param requested the resource as requested; may be null, and its characteristic set may be null
     */
    public ResourceEstimator.Profile estimateProfile(ResourceUpdate requested) {
        return estimateProfile();
    }

    /**
     * The controller logic for this spec: read the input characteristics, compute the {@code status.*}
     * ones, report AVAILABLE.
+56 −3
Changes for src/main/java/org/etsi/osl/example/gc/api/RadioEngineer.java: 56 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,6 +21,10 @@ import org.slf4j.LoggerFactory;
 * from a controller that declined to estimate, which reports null. Its cost is a labour rate per hour,
 * which is why "cost units" are deliberately dimensionless rather than a power tariff.
 *
 * <p>It is likewise the only spec here that is not always-on, and the reason {@link ResourceEstimator}
 * has a duty cycle at all: the labour rate is billed per hour <b>worked</b>, not per hour elapsed. A
 * 30-day engagement of an 8-hour shift costs 240 hours of labour, not 720.
 *
 * <p>Illustrative only — the "logic" makes up a dispatch reference. Nobody is actually assigned.
 */
public class RadioEngineer extends ExampleResourceSpec {
@@ -30,9 +34,12 @@ public class RadioEngineer extends ExampleResourceSpec {
    public static final String RESOURCE_NAME = "RadioEngineer";
    public static final String RESOURCE_DESCRIPTION = "A field radio engineer assigned to a job";

    /** One shift. Shared by the characteristic default and the estimate, so the two cannot drift apart. */
    private static final double DEFAULT_SHIFT_HOURS = 8;

    private String skillLevel = "senior";
    private String region = "central";
    private String shiftHours = "8";
    private String shiftHours = String.valueOf((int) DEFAULT_SHIFT_HOURS);

    public RadioEngineer(String version, String category) {
        super(RESOURCE_NAME, version, RESOURCE_DESCRIPTION, category);
@@ -53,11 +60,57 @@ public class RadioEngineer extends ExampleResourceSpec {
    /**
     * Creation is travel-and-brief, teardown is wrap-up; both cost labour. The three zeros are Watt-hours
     * and are meant literally: an engineer consumes no grid power as a network resource. Only the cost
     * rate is non-zero while "operating" — it is a wage, not a tariff.
     * rate is non-zero while "operating" — it is a wage, not a tariff, and it is charged for
     * {@link #DEFAULT_SHIFT_HOURS} of each day rather than all 24.
     *
     * <p>This is the figure used when there is no request to consult — the CREATION and TEARDOWN
     * callbacks, which are shift-independent anyway. {@link #estimateProfile(ResourceUpdate)} is what
     * sizes the OPERATION phase.
     */
    @Override
    public ResourceEstimator.Profile estimateProfile() {
        return new ResourceEstimator.Profile(0, 0, 100.0, 0.0, 45.0, 0, 0, 0);
        return profileFor(DEFAULT_SHIFT_HOURS);
    }

    /** Bills the shift length this request actually asked for. */
    @Override
    public ResourceEstimator.Profile estimateProfile(ResourceUpdate requested) {
        return profileFor(shiftHoursFrom(requested));
    }

    private static ResourceEstimator.Profile profileFor(double hoursPerDay) {
        return new ResourceEstimator.Profile(0, 0, 100.0, 0.0, 45.0, hoursPerDay, 0, 0, 0);
    }

    /**
     * The requested shift length, falling back to {@link #DEFAULT_SHIFT_HOURS} when the characteristic is
     * absent, unparseable, or outside a day a person could work. Reads the request rather than the
     * {@code shiftHours} field on purpose: this spec is a singleton shared across concurrent planning
     * requests, so the field is whatever the last {@code resourceLogic} happened to leave behind.
     */
    private static double shiftHoursFrom(ResourceUpdate requested) {
        if (requested == null || requested.getResourceCharacteristic() == null) {
            return DEFAULT_SHIFT_HOURS;
        }
        for (Characteristic c : requested.getResourceCharacteristic()) {
            if (c.getName() == null || !c.getName().equalsIgnoreCase("shiftHours")
                    || c.getValue() == null || c.getValue().getValue() == null) {
                continue;
            }
            try {
                double hours = Double.parseDouble(c.getValue().getValue().trim());
                if (hours > 0 && hours <= 24) {
                    return hours;
                }
                logger.warn("Ignoring out-of-range shiftHours '" + c.getValue().getValue()
                        + "'; estimating against " + DEFAULT_SHIFT_HOURS + "h");
            } catch (NumberFormatException e) {
                logger.warn("Ignoring unparseable shiftHours '" + c.getValue().getValue()
                        + "'; estimating against " + DEFAULT_SHIFT_HOURS + "h");
            }
            return DEFAULT_SHIFT_HOURS;
        }
        return DEFAULT_SHIFT_HOURS;
    }

    @Override
+29 −6
Changes for src/main/java/org/etsi/osl/example/gc/api/ResourceEstimator.java: 29 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -11,7 +11,12 @@ package org.etsi.osl.example.gc.api;
 *
 * <p>The model is deliberately trivial: each spec has fixed creation and teardown costs, plus a
 * steady-state power draw and cost rate; the OPERATION phase is those rates multiplied by the window
 * OSOM supplies in the {@code org.etsi.osl.operationDurationSeconds} request header.
 * OSOM supplies in the {@code org.etsi.osl.operationDurationSeconds} request header, and by the spec's
 * duty cycle — the share of each day it is actually in service. Everything this controller deploys is
 * always-on except {@code RadioEngineer}, who works a shift.
 *
 * <p>The duty cycle scales energy and cost only. The reported duration stays the raw window: it echoes
 * back what OSOM asked us to size against, and is not a claim about hours worked.
 *
 * <p>Units, fixed by the header contract and never transmitted: seconds, Watt-hours (Wh), and
 * dimensionless cost units.
@@ -39,13 +44,25 @@ public final class ResourceEstimator {
     * @param creationCost
     * @param operationWatts steady-state power draw while in service
     * @param operationCostPerHour steady-state cost rate while in service
     * @param operationHoursPerDay hours of each day the resource is actually in service; 24 for
     *        anything always-on, less for a resource that works a shift
     * @param teardownSeconds
     * @param teardownWh
     * @param teardownCost
     */
    public record Profile(double creationSeconds, double creationWh, double creationCost,
                          double operationWatts, double operationCostPerHour,
                          double teardownSeconds, double teardownWh, double teardownCost) {}
                          double operationHoursPerDay,
                          double teardownSeconds, double teardownWh, double teardownCost) {

        /** Always-on: in service for every hour of the window. The default for anything that is not a person. */
        public Profile(double creationSeconds, double creationWh, double creationCost,
                       double operationWatts, double operationCostPerHour,
                       double teardownSeconds, double teardownWh, double teardownCost) {
            this(creationSeconds, creationWh, creationCost, operationWatts, operationCostPerHour,
                    24.0, teardownSeconds, teardownWh, teardownCost);
        }
    }

    /** CREATION estimate; null for a null profile (a spec this controller does not implement). */
    public static Phase creation(Profile p) {
@@ -53,8 +70,10 @@ public final class ResourceEstimator {
    }

    /**
     * OPERATION estimate over the given window: power x hours for energy, rate x hours for cost. The
     * reported duration is the window itself, echoing back what OSOM asked us to size against.
     * OPERATION estimate over the given window: power x active hours for energy, rate x active hours for
     * cost, where active hours are the window scaled by the profile's duty cycle. The reported duration
     * is the window itself, echoing back what OSOM asked us to size against — a resource that works 8
     * hours a day is still engaged for the whole 30 days.
     *
     * @param operationDurationSeconds the window from {@code org.etsi.osl.operationDurationSeconds}
     * @return null for a null profile (a spec this controller does not implement)
@@ -64,9 +83,13 @@ public final class ResourceEstimator {
            return null;
        }
        double hours = operationDurationSeconds / 3600.0;
        // Clamped: operationHoursPerDay can originate in a request characteristic, and neither a
        // negative day (a cost rebate) nor a 30-hour one is a figure this model should ever report.
        double dutyCycle = Math.min(Math.max(p.operationHoursPerDay(), 0.0), 24.0) / 24.0;
        double activeHours = hours * dutyCycle;
        return new Phase((double) operationDurationSeconds,
                p.operationWatts() * hours,
                p.operationCostPerHour() * hours);
                p.operationWatts() * activeHours,
                p.operationCostPerHour() * activeHours);
    }

    /** TEARDOWN estimate; null for a null profile (a spec this controller does not implement). */
Loading