Skip to content

Feature/stoichiometry for Animals#726

Merged
TaranRallings merged 32 commits intodevelopfrom
feature/stoichiometry
Feb 20, 2025
Merged

Feature/stoichiometry for Animals#726
TaranRallings merged 32 commits intodevelopfrom
feature/stoichiometry

Conversation

@TaranRallings
Copy link
Copy Markdown
Collaborator

Description

This PR contains the transition from a purely carbon mass system to a carbon, nitrogen, phosphorus (cnp) system.

Key Changes:

  • the cnp mass dictionary is now the core mass used in most methods
  • mass_current (total mass) is now a property, as is reproductive_mass, as the total values are sometimes needed
  • a 'grow' method has been implemented in AnimalCohort to ensure stoichiometric ratios are adhered to
  • a lot of tests were reworked and expanded.

Fixes # (issue)

Type of change

  • New feature (non-breaking change which adds functionality)
  • Optimization (back-end change that speeds up the code)
  • Bug fix (non-breaking change which fixes an issue)

Key checklist

  • Make sure you've run the pre-commit checks: $ pre-commit run -a
  • All tests pass: $ poetry run pytest

Further checks

  • Code is commented, particularly in hard-to-understand areas
  • Tests added that prove fix is effective or that feature works
  • Relevant documentation reviewed and updated

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Feb 5, 2025

Codecov Report

Attention: Patch coverage is 93.17406% with 20 lines in your changes missing coverage. Please review.

Project coverage is 94.68%. Comparing base (399e882) to head (9690f41).
Report is 608 commits behind head on develop.

Files with missing lines Patch % Lines
virtual_ecosystem/models/animal/animal_cohorts.py 92.15% 12 Missing ⚠️
virtual_ecosystem/models/animal/decay.py 91.80% 5 Missing ⚠️
virtual_ecosystem/models/animal/plant_resources.py 88.88% 2 Missing ⚠️
virtual_ecosystem/models/animal/animal_model.py 96.29% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #726      +/-   ##
===========================================
- Coverage    94.93%   94.68%   -0.26%     
===========================================
  Files           73       74       +1     
  Lines         4998     5135     +137     
===========================================
+ Hits          4745     4862     +117     
- Misses         253      273      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Collaborator

@jacobcook1995 jacobcook1995 left a comment

Choose a reason for hiding this comment

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

I've looked at the exchanges with the litter/soil model and I am happy that information will still get passed into my models in the format I want it in. I'm not approving as I don't feel well placed to give feedback on the overall structure/approach.

I had one specific comment which directly contradicts what I told you on teams earlier, sorry!

self.leaf_waste_pools[cell_id].mass_cnp["carbon"]
/ self.leaf_waste_pools[cell_id].mass_cnp["phosphorus"]
if self.leaf_waste_pools[cell_id].mass_cnp["phosphorus"] > 0
else 0.0
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry despite what I said earlier using 0.0 here doesn't really work, but obviously using inf for the divide by zero case would break the downstream calculations. Not 100% sure what the best approach is here, I guess this is a value that you should never expect, so maybe we just accept this as a weird edge case?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay actually think you want to add the following between the if and the else

elif self.leaf_waste_pools[cell_id].mass_cnp["carbon"] > 0 np.inf```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(the above might not be valid, but you get the idea)

This means that if there is a carbon flow with no associated nitrogen/phosphorus an infinite value gets returned (which then raises an error downstream). And this makes sense because biological matter always contains N and P.

But this handles the "there's no phosphorus because there's no organic matter at all" case gracefully

efficiency = detritivore.functional_group.mechanical_efficiency
actual_consumed_mass = actually_available_mass * efficiency

# Avoid division by zero in nutrient fractions calculation
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You actually take a step to avoid division errors here, is there a reason not to do this in the plant waste products case above?

Copy link
Copy Markdown
Collaborator

@dalonsoa dalonsoa left a comment

Choose a reason for hiding this comment

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

I have not gone into the detail of the code, but don't have much time today and wanted to give you some early feedback. This very much calls for a dedicated class in which to put together all the cnp related manipulations. Something like the following will also facilitate manipulation and type checks:

@dataclass 
class CNP:
    carbon: float
    nitrogen: float
    phosphorus: float

    @property
    def total(self) -> float:
        return self.carbon + self.nitrogen + self.phosphorus

    def __getitem__(self, key):
        """In case you want dictionary-style access."""
        return getattr(self, key)

Or, conversely, you can make it inherit from dict and add properties, as needed:

class CNP(dict):

    @property
    def carbon(self) -> float:
        return self["carbon"]

    @property
    def nitrogen(self) -> float:
        return self["nitrogen"]

    @property
    def phosphorus(self) -> float:
        return self["phosphorus"]

    @property
    def total(self) -> float:
        return sum(self.values())

Given that CNP is something that seems to have some entity as a group, I think it deserves to be treated as such in the code, also facilitating its documentation.

@TaranRallings
Copy link
Copy Markdown
Collaborator Author

This very much calls for a dedicated class in which to put together all the cnp related manipulations.

@dalonsoa This is a really good idea and I'm annoyed that I didn't think of it! I'll make the adjustments.

Copy link
Copy Markdown
Collaborator

@jacobcook1995 jacobcook1995 left a comment

Choose a reason for hiding this comment

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

I'm happy with how the links to soil/litter now work!

Copy link
Copy Markdown
Collaborator

@dalonsoa dalonsoa left a comment

Choose a reason for hiding this comment

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

Very nice change! It looks cleaner and more robust. I only one comment, although it is a big one.

Comment on lines +55 to +77
def add(self, carbon: float, nitrogen: float, phosphorus: float) -> None:
"""Add the provided amounts of C, N, and P to the current CNP object.

Args:
carbon (float): The mass of carbon to add.
nitrogen (float): The mass of nitrogen to add.
phosphorus (float): The mass of phosphorus to add.
"""
self.carbon += carbon
self.nitrogen += nitrogen
self.phosphorus += phosphorus

def subtract(self, carbon: float, nitrogen: float, phosphorus: float) -> None:
"""Subtract the provided amounts of C, N, and P from the current CNP object.

Args:
carbon (float): The mass of carbon to subtract.
nitrogen (float): The mass of nitrogen to subtract.
phosphorus (float): The mass of phosphorus to subtract.
"""
self.carbon -= carbon
self.nitrogen -= nitrogen
self.phosphorus -= phosphorus
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

First, why not just using negative number for subtracting and merging these two functions in a single update one?

Second, you might want to force these to be keyword argument-only functions and provide default values of 0 for all of them. Might not save typing, but will make using them more robust and less prone to error as you need to be explicit about what input is what.

Suggested change
def add(self, carbon: float, nitrogen: float, phosphorus: float) -> None:
"""Add the provided amounts of C, N, and P to the current CNP object.
Args:
carbon (float): The mass of carbon to add.
nitrogen (float): The mass of nitrogen to add.
phosphorus (float): The mass of phosphorus to add.
"""
self.carbon += carbon
self.nitrogen += nitrogen
self.phosphorus += phosphorus
def subtract(self, carbon: float, nitrogen: float, phosphorus: float) -> None:
"""Subtract the provided amounts of C, N, and P from the current CNP object.
Args:
carbon (float): The mass of carbon to subtract.
nitrogen (float): The mass of nitrogen to subtract.
phosphorus (float): The mass of phosphorus to subtract.
"""
self.carbon -= carbon
self.nitrogen -= nitrogen
self.phosphorus -= phosphorus
def add(self, *, carbon: float = 0, nitrogen: float = 0, phosphorus: float = 0) -> None:
"""Add the provided amounts of C, N, and P to the current CNP object.
Args:
carbon (float): The mass of carbon to add.
nitrogen (float): The mass of nitrogen to add.
phosphorus (float): The mass of phosphorus to add.
"""
self.carbon += carbon
self.nitrogen += nitrogen
self.phosphorus += phosphorus
def subtract(self, *, carbon: float = 0, nitrogen: float = 0, phosphorus: float = 0) -> None:
"""Subtract the provided amounts of C, N, and P from the current CNP object.
Args:
carbon (float): The mass of carbon to subtract.
nitrogen (float): The mass of nitrogen to subtract.
phosphorus (float): The mass of phosphorus to subtract.
"""
self.carbon -= carbon
self.nitrogen -= nitrogen
self.phosphorus -= phosphorus

So you can do things like cnp.add(phosphorus=42) or cnp.substract(nitrogen=17). But, AS I said, I think it will be cleaner to have cnp.update(phosphorus=42) and cnp.update(nitrogen=-17).

Finally, does it make sense to have a any of these negative? If not, there should be an error or a warning whenever the total of any of these quantities become negative.

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.

That makes sense. I'll make the change.

Copy link
Copy Markdown
Collaborator

@dalonsoa dalonsoa left a comment

Choose a reason for hiding this comment

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

Just a minor change, but this looks good to me!

TaranRallings and others added 2 commits February 20, 2025 10:08
Small code improvement in cnp._validate_non_negative

Co-authored-by: Diego Alonso Álvarez <6095790+dalonsoa@users.noreply.github.com>
@TaranRallings TaranRallings merged commit 095479f into develop Feb 20, 2025
@TaranRallings TaranRallings deleted the feature/stoichiometry branch February 20, 2025 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants