Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
29baebf
Interim commit
Alomir Apr 21, 2026
95fc40b
Merge branch 'master' into SIP256-Leaf-on-off-events
Alomir Apr 23, 2026
50102a1
Updates for leaf on/off events
Alomir Apr 23, 2026
95b5276
Initial plan for leaf on/off tests
Copilot Apr 23, 2026
72a66f8
Add leaf on/off event tests and fix event processing bugs
Copilot Apr 23, 2026
73a7611
Fix comment spelling in testBalance.c
Copilot Apr 23, 2026
4ed698e
Merge branch 'master' into SIP256-Leaf-on-off-events
Alomir Apr 28, 2026
cfabbee
Clean up comment
Alomir Apr 28, 2026
506181e
Move to tests/utils, rename
Alomir Apr 28, 2026
5fc0ed3
Update for moved typesUtils
Alomir Apr 28, 2026
66f53b4
Added leafon/leafoff events
Alomir Apr 28, 2026
b5419ba
Format changes
Alomir Apr 28, 2026
90e4e4c
Add leaf on/off doc
Alomir Apr 28, 2026
34a2387
Fix leaf on/off event types
Alomir Apr 28, 2026
be248c4
Fix typo
Alomir Apr 28, 2026
9461232
Update missed event rename in writeEventOut
Alomir Apr 28, 2026
b9d97c2
Init climate node more
Alomir Apr 28, 2026
942bd0f
Tweaks from PR review
Alomir Apr 28, 2026
96e75c6
Update slack link
Alomir Apr 29, 2026
49ff24f
Add 0 (turn off) val for leafOnDay/leafOffDay
Alomir Apr 29, 2026
2896cf8
Add check for calc'd leaf events when leaf events appear in events.in
Alomir Apr 29, 2026
50d4281
Use 0 value as shutoff for leafOnDay/leafOffDay
Alomir Apr 29, 2026
090dc09
Exclude slack invite link
Alomir Apr 29, 2026
d7201e6
Fix leaf event param order
Alomir Apr 29, 2026
f322288
Update for leaf on/off check
Alomir Apr 29, 2026
c2bbae9
Fix regex typo
Alomir Apr 29, 2026
d9866d0
Add checkForCalculateLeafEvents tests to testEventLeafOnOff.c
Copilot Apr 29, 2026
a512961
Update name to checkForCalculatedLeafEvents
Alomir Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
--format markdown
--exclude '^https://doi\.org/'
--exclude '^https://russellranch\.ucdavis\.edu'
--exclude '^https://join\.slack\.com'
.
fail: true
env:
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ sections to include in release notes:
### Added

- `sipnet-view` tool for visualizing SIPNET output files (#317)
- `leafon` and `leafoff` events for tracking phenological transitions (#326)
Comment thread
Alomir marked this conversation as resolved.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ SIPNET (Simplified Photosynthesis and Evapotranspiration Model) is a lightweight
- **Understanding the code:** The [developer docs](model-structure.md) describe architecture, while sections on [testing](developer-guide/testing.md) and [CLI extensions](developer-guide/cli-options.md) cover contributions.
- **Project practices:** Review the [Contributing guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Changelog](CHANGELOG.md) before opening PRs or issues.

Need help? Open an issue on [GitHub](https://github.com/PecanProject/sipnet/issues) or join the [PEcAn community Slack](https://join.slack.com/t/pecanproject/shared_invite/zt-3ile31ylu-r1sMh~esl~7A_AolYNNYLQ).
Need help? Open an issue on [GitHub](https://github.com/PecanProject/sipnet/issues) or join the [PEcAn community Slack](https://join.slack.com/t/pecanproject/shared_invite/zt-3wlzwms7n-mmoLuYsIfaDqp_FRZD1eQQ).

## License

Expand Down
26 changes: 26 additions & 0 deletions docs/model-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,32 @@ f_{\text{intercept}} \, F^W_{\text{irrig}}, & I_{\text{irrigation}} = 0 \\
\label{eq:irrig_evap}
\end{equation}

### Leaf On/Leaf Off

Leaf on and leaf off events define the timing of leaf emergence and senescence, respectively. These events directly
specify the amount of carbon added to the leaf carbon pool on the leaf on date, and the fraction of carbon removed from
the leaf carbon pool on the leaf off date.

When a leaf on event occurs, an amount of carbon (specified by the `leafGrowth` parameter) is transferred from the wood
carbon pool to the leaf carbon pool. As leaf C:N is usually lower than wood C:N, the excess nitrogen
implied by the static C:N ratios is included as part of the plant nitrogen demand. If there is insufficient nitrogen
available for this lump-sum move, nitrogen limitation will occur.

When a leaf off event occurs, a fraction of the leaf carbon (specified by the `fracLeafFall` parameter) is transferred
from the leaf carbon pool to the litter pool (or soil pool, if the litter pool is not being used). The corresponding
nitrogen (calculated from the leaf C:N ratio) is also transferred to the litter or soil nitrogen pool.

**Event parameters:**

| Parameter | Value | Description |
|-----------|----------------------|-------------------|
| Year | integer | Year |
| Day | integer | Day of year |
| Type | `leafon` / `leafoff` | The type of event |

There are no other parameters needed for these events, as the amount of transfer is determined by the parameters
mentioned above.

<!--
**Flooding** increases soil water to water holding capacity and then adds water equivalent to the depth of flooding. Subsequent irrigation events maintain flooding by topping off water content.

Expand Down
22 changes: 11 additions & 11 deletions docs/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,21 @@ Run-time parameters can change from one run to the next, or when the model is st

### Phenology-Related Parameters

| Symbol | Parameter Name | Definition | Units | Notes |
| ------------------------ | ---------------- | --------------------------------------------------------------------- | -------------------------------- | ------------------------------------ |
| $D_{\text{on}}$ | leafOnDay | Day of year when leaves appear | unitless | day of year (1–365) |
| $GDD_{\text{on}}$ | gddLeafOn | GDD threshold for leaf appearance (GDD-based phenology) | $°\text{C} \cdot \text{day}$ | |
| $T_{\text{on}}$ | soilTempLeafOn | Soil temperature threshold for leaf appearance (temp-based phenology) | $°\text{C}$ | |
| $D_{\text{off}}$ | leafOffDay | Day of year for leaf drop | unitless | day of year (1–365) |
| $\Delta C_{\text{leaf}}$ | leafGrowth | Additional leaf growth at start of growing season | $\text{g C} \cdot \text{m}^{-2}$ | |
| $f_{\text{fall}}$ | fracLeafFall | Additional fraction of leaves that fall at end of growing season | unitless | |
| $\alpha_{\text{leaf}}$ | leafAllocation | Fraction of $NPP$ allocated to leaf growth | unitless | |
| $K_{\text{leaf}}$ | leafTurnoverRate | Average turnover rate of leaves | $\text{year}^{-1}$ | Converted to per-day rate internally |
| Symbol | Parameter Name | Definition | Units | Notes |
|---------------------------|------------------|-----------------------------------------------------------------------|----------------------------------|--------------------------------------|
| $D_{\text{on}}$ | leafOnDay | Day of year when leaves appear | unitless | day of year (1–365); 0 to turn off |
| $GDD_{\text{on}}$ | gddLeafOn | GDD threshold for leaf appearance (GDD-based phenology) | $°\text{C} \cdot \text{day}$ | |
| $T_{\text{on}}$ | soilTempLeafOn | Soil temperature threshold for leaf appearance (temp-based phenology) | $°\text{C}$ | |
| $D_{\text{off}}$ | leafOffDay | Day of year for leaf drop | unitless | day of year (1–365); 0 to turn off |
| $\Delta C_{\text{leaf}}$ | leafGrowth | Additional leaf growth at start of growing season | $\text{g C} \cdot \text{m}^{-2}$ | |
| $f_{\text{fall}}$ | fracLeafFall | Additional fraction of leaves that fall at end of growing season | unitless | |
| $\alpha_{\text{leaf}}$ | leafAllocation | Fraction of $NPP$ allocated to leaf growth | unitless | |
| $K_{\text{leaf}}$ | leafTurnoverRate | Average turnover rate of leaves | $\text{year}^{-1}$ | Converted to per-day rate internally |

### Allocation Parameters

| Symbol | Parameter Name | Definition | Units | Notes |
| ----------------------------- | -------------------- | ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|-------------------------------|----------------------|---------------------------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| $\alpha_{\text{fine root}}$ | fineRootAllocation | Fraction of $NPP$ allocated to fine roots | unitless | |
| $\alpha_{\text{coarse root}}$ | coarseRootAllocation | Fraction of $NPP$ allocated to coarse roots | unitless | Calculated internally from remainder: $\alpha_{\text{coarse root}} = 1 - \alpha_{\text{leaf}} - \alpha_{\text{wood}} - \alpha_{\text{fine root}}$ |
| $\alpha_{\text{wood}}$ | woodAllocation | Fraction of $NPP$ allocated to wood | unitless | |
Expand Down
186 changes: 145 additions & 41 deletions src/sipnet/events.c
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,32 @@ EventNode *createEventNode(int year, int day, int eventType,
tParams->tillageEffect = tillEffect;
newEvent->eventParams = tParams;
} break;
case LEAFON: {
double dummy;
LeafOnParams *lParams = (LeafOnParams *)malloc(sizeof(LeafOnParams));
// Check for extraneous data: leafon takes no parameters, so error if any
// numbers are found. sscanf returns 0 or EOF (-1) for empty/whitespace.
int numRead = sscanf(eventParamsStr, // NOLINT
"%lf", &dummy);
if (numRead > NUM_LEAFON_PARAMS) {
logError("parsing LeafOn params for year %d day %d\n", year, day);
exit(EXIT_CODE_INPUT_FILE_ERROR);
}
newEvent->eventParams = lParams;
} break;
case LEAFOFF: {
double dummy;
LeafOffParams *lParams = (LeafOffParams *)malloc(sizeof(LeafOffParams));
// Check for extraneous data: leafoff takes no parameters, so error if
// any numbers are found. sscanf returns 0 or EOF (-1) for empty input.
int numRead = sscanf(eventParamsStr, // NOLINT
"%lf", &dummy);
if (numRead > NUM_LEAFOFF_PARAMS) {
Comment thread
Alomir marked this conversation as resolved.
logError("parsing LeafOff params for year %d day %d\n", year, day);
exit(EXIT_CODE_INPUT_FILE_ERROR);
}
newEvent->eventParams = lParams;
} break;
default:
// Unknown type, error and exit
logError("found unknown event type %d while reading event file\n",
Expand All @@ -159,6 +185,10 @@ const char *eventTypeToString(event_type_t type) {
return "fert";
case TILLAGE:
return "till";
case LEAFON:
return "leafon";
case LEAFOFF:
return "leafoff";
default:
logError("unknown event type in eventTypeToString (%d)", type);
exit(EXIT_CODE_UNKNOWN_EVENT_TYPE_OR_PARAM);
Expand All @@ -168,15 +198,26 @@ const char *eventTypeToString(event_type_t type) {
event_type_t eventStringToType(const char *eventTypeStr) {
if (strcmp(eventTypeStr, "irrig") == 0) {
return IRRIGATION;
} else if (strcmp(eventTypeStr, "fert") == 0) {
}
if (strcmp(eventTypeStr, "fert") == 0) {
return FERTILIZATION;
} else if (strcmp(eventTypeStr, "plant") == 0) {
}
if (strcmp(eventTypeStr, "plant") == 0) {
return PLANTING;
} else if (strcmp(eventTypeStr, "till") == 0) {
}
if (strcmp(eventTypeStr, "till") == 0) {
return TILLAGE;
} else if (strcmp(eventTypeStr, "harv") == 0) {
}
if (strcmp(eventTypeStr, "harv") == 0) {
return HARVEST;
}
if (strcmp(eventTypeStr, "leafon") == 0) {
return LEAFON;
}
if (strcmp(eventTypeStr, "leafoff") == 0) {
return LEAFOFF;
}

return UNKNOWN_EVENT;
}

Expand All @@ -193,6 +234,18 @@ static void checkEventLineTruncation(const char *line, size_t len) {
}
}

void checkForCalculatedLeafEvents(void) {
// We have a leaf event in events.in, so make sure we are not also
// calculating leaf events
if (ctx.gdd || ctx.soilPhenol || params.leafOnDay > 0 ||
params.leafOffDay > 0) {
logError("calculated leaf events (via leafOnDay/leafOffDay params or "
"gdd/soil-phenol command-line options) are not compatible "
"with user-specified leaf events in event file\n");
exit(EXIT_CODE_BAD_PARAMETER_VALUE);
}
}

EventNode *readEventData(const char *eventFile) {
int year, day, eventType;
int currYear, currDay;
Expand All @@ -202,6 +255,7 @@ EventNode *readEventData(const char *eventFile) {
char line[EVENT_LINE_SIZE];
EventNode *curr, *next;
EventNode *newEvents = NULL;
int hasCheckedLeafEvents = 0;

// Check for a non-empty file
if (access(eventFile, F_OK) != 0) {
Expand Down Expand Up @@ -238,6 +292,13 @@ EventNode *readEventData(const char *eventFile) {
exit(EXIT_CODE_UNKNOWN_EVENT_TYPE_OR_PARAM);
}

if (eventType == LEAFOFF || eventType == LEAFON) {
// If we have a leaf event, make sure we aren't also looking for
// calculated leaf events.
checkForCalculatedLeafEvents();
hasCheckedLeafEvents = 1;
}

newEvents = createEventNode(year, day, eventType, eventParamsStr);
next = newEvents;
currYear = year;
Expand All @@ -264,12 +325,20 @@ EventNode *readEventData(const char *eventFile) {
exit(EXIT_CODE_UNKNOWN_EVENT_TYPE_OR_PARAM);
}

if (eventType == LEAFOFF || eventType == LEAFON) {
// If we have a leaf event, make sure we aren't also looking for
// calculated leaf events.
if (!hasCheckedLeafEvents) {
checkForCalculatedLeafEvents();
hasCheckedLeafEvents = 1;
}
}

if ((year < currYear) || ((year == currYear) && (day < currDay))) {
// clang-format off
logError("reading event file: last event was at (%d, %d), next event is "
"at (%d, %d)\n", currYear, currDay, year, day);
"at (%d, %d)\n",
currYear, currDay, year, day);
logError("event records must be in time-ascending order\n");
// clang-format on
exit(EXIT_CODE_INPUT_FILE_ERROR);
}

Expand Down Expand Up @@ -363,30 +432,9 @@ int isFirstEventBefore(int year, int day) {
return firstEvent->day < day;
}

void resetEventFluxes(void) {
fluxes.eventLeafC = 0.0;
fluxes.eventWoodC = 0.0;
fluxes.eventFineRootC = 0.0;
fluxes.eventCoarseRootC = 0.0;
fluxes.eventEvap = 0.0;
fluxes.eventSoilWater = 0.0;
fluxes.eventSoilC = 0.0;
fluxes.eventLitterC = 0.0;
fluxes.eventMinN = 0.0;
fluxes.eventSoilOrgN = 0.0;
fluxes.eventLitterN = 0.0;

// mass balance
fluxes.eventInputC = 0.0;
fluxes.eventOutputC = 0.0;
fluxes.eventInputN = 0.0;
fluxes.eventOutputN = 0.0;
}

void processEvents(void) {
// Set all event fluxes to zero, as these have no memory from one step to
// the next
resetEventFluxes();
// Event fluxes have all been reset to zero at the start of the time step,
// so we can just add to them as needed

// If event starts off NULL, this function will just fall through, as it
// should.
Expand Down Expand Up @@ -537,15 +585,20 @@ void processEvents(void) {
fracRB);
fluxes.eventOutputN += outputN / climLen;
}
// clang-format off
writeEventOut(
gEvent, 10, "fluxes.eventSoilC", soilAdd / climLen,
"fluxes.eventLitterC", litterAdd / climLen, "fluxes.eventLeafC",
leafDelta / climLen, "fluxes.eventWoodC", woodDelta / climLen,
gEvent, 10,
"fluxes.eventSoilC", soilAdd / climLen,
"fluxes.eventLitterC", litterAdd / climLen,
"fluxes.eventLeafC", leafDelta / climLen,
"fluxes.eventWoodC", woodDelta / climLen,
"fluxes.eventFineRootC", fineDelta / climLen,
"fluxes.eventCoarseRootC", coarseDelta / climLen,
"fluxes.eventSoilOrgN", soilNAdd / climLen, "fluxes.eventLitterN",
litterNAdd / climLen, "fluxes.eventOutputC", outputC / climLen,
"fluxes.eventSoilOrgN", soilNAdd / climLen,
"fluxes.eventLitterN", litterNAdd / climLen,
"fluxes.eventOutputC", outputC / climLen,
"fluxes.eventOutputN", outputN / climLen);
// clang-format on
} break;
case TILLAGE: {
// BIG NOTE: this is the one event type that is NOT modeled as a flux;
Expand All @@ -561,8 +614,12 @@ void processEvents(void) {
case FERTILIZATION: {
const FertilizationParams *fertParams = gEvent->eventParams;
const double orgC = fertParams->orgC;
double orgN = fertParams->orgN;
double minN = fertParams->minN;
double orgN = 0.0;
double minN = 0.0;
if (ctx.nitrogenCycle) {
orgN = fertParams->orgN;
minN = fertParams->minN;
}
if (ctx.litterPool) {
fluxes.eventLitterC += orgC / climLen;
} else {
Expand All @@ -583,10 +640,47 @@ void processEvents(void) {
fluxes.eventInputN += (orgN + minN) / climLen;
}

writeEventOut(gEvent, 5, "fluxes.eventOrgN", orgN / climLen,
"fluxes.eventLitterC", orgC / climLen, "fluxes.eventMinN",
minN / climLen, "fluxes.eventInputC", orgC / climLen,
"fluxes.eventInputN", (orgN + minN) / climLen);
// clang-format off
writeEventOut(gEvent, 6,
"fluxes.eventLitterC", ctx.litterPool ? orgC / climLen : 0.0,
"fluxes.eventSoilC", ctx.litterPool ? 0.0 : orgC / climLen,
"fluxes.eventMinN", minN / climLen,
"fluxes.eventLitterN", orgN / climLen,
"fluxes.eventInputC", orgC / climLen,
"fluxes.eventInputN", (orgN + minN) / climLen);
// clang-format on
} break;
case LEAFON: {
double leafOn = params.leafGrowth;
fluxes.eventLeafOnCreation += leafOn / climLen;

// Nitrogen is handled implicitly by relative CN ratios. Missing N
// from low-N wood to higher-N leaves is accounted for in
// calcNFixationAndUptakeFluxes() via calcPlantNDemand()

// Unlike planting, this is NOT a system input, so no adjustments to
// eventInputC or eventInputN

// ALSO, unlike all other events, we don't write the event here, as N
// limitation may change the amount
} break;
case LEAFOFF: {
double leafOff = envi.plantLeafC * params.fracLeafFall;
fluxes.eventLeafOffLitter += leafOff / climLen;

double litterNAdd = 0.0;
if (ctx.nitrogenCycle) {
// Nitrogen - need to account for leaf N moving to litter, as with
// harvests
litterNAdd = leafOff / params.leafCN;
fluxes.eventLitterN += litterNAdd / climLen;
}

// clang-format off
writeEventOut(gEvent, 2,
"fluxes.eventLeafOffLitter", leafOff / climLen,
"fluxes.eventLitterN", litterNAdd / climLen);
// clang-format on
} break;
default:
logError("Unknown event type (%d) in processEvents()\n", gEvent->type);
Expand All @@ -609,6 +703,16 @@ void updatePoolsForEvents(void) {
envi.litterC += fluxes.eventLitterC * climate->length;
}

// Leaf on and off events
envi.plantWoodC -= fluxes.eventLeafOnCreation * climate->length;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if eventLeafOnCreation > plantWoodC?

  • should leaf creation be limited to
    • available plantWoodC?
    • total wood C (plantWoodC + plantWoodCStorageDelta)
  • or should it be unconstrained, and allowed to draw from plantWoodCStorageDelta?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same thing that happens if leafOnCreation (the non-event one) is bigger than plantWoodC - that is, nothing good.

I didn't attempt to address that here, as I think that's a higher-level question (that needs answering, of course!). This PR is scoped to "replicate leaf on/off as events".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even constraining it to plantWoodC is going to leave plantWoodC at zero - is that tenable? (See ongoing plant death discussions)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What processes are allowed to draw from the storage delta currently? If I'm thinking about it right as "a highly mobile pool of presumably-recent photosynthate" then it seems reasonable to me for leaf growth to draw from it before any other pool.

envi.plantLeafC += (fluxes.eventLeafOnCreation - fluxes.eventLeafOffLitter) *
climate->length;
if (ctx.litterPool) {
envi.litterC += fluxes.eventLeafOffLitter * climate->length;
} else {
envi.soilC += fluxes.eventLeafOffLitter * climate->length;
}

// Harvest and planting events
envi.coarseRootC += fluxes.eventCoarseRootC * climate->length;
envi.fineRootC += fluxes.eventFineRootC * climate->length;
Expand Down
Loading
Loading