From 483cbd212469032a365cd189dd0f12e36e7d86bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:00:55 +0000 Subject: [PATCH] 12 Faster seeds, shading and deaths, with the same results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Builds on PR 8 (#24)**, the end of the first series (PR 9's fix is already in terrain-org). It doesn't depend on PR 10 or PR 11. [This PR's changes on their own](https://github.com/adamf/vida/compare/pr8-web-viewer...pr12-faster-seeds). ## Faster seeds, shading and deaths, with the same results About a third off the running time, and **no change to any result**: the characterization tests pass unchanged. A tiny deliberate change to the photon loop (a radius 0.1% smaller) makes three scenarios fail, so they do check this code. | Run (best of two) | PR 8 | This PR | |---|---|---| | 100 m world, 400 seeds, 50 cycles | 9.1 s | 5.8 s | | 150 m world, 900 seeds, 40 cycles | 11.2 s | 7.6 s | ### Making a seed (`copyForNewSeed` in `vplantr.py`) PR 6 stopped a new seed copying its parent's whole family tree, but each of the parent's ~100 settings still went through `copy.deepcopy`, 53,000 times a run. - **Numbers, strings, True/False and None** can't be changed in place, and `deepcopy` gave back the very same value for them anyway. So the seed now shares them. - **A list of such values** (the colours, the growth records) gets a new list with the same values in it, which is also what `deepcopy` made. The seed still has its own copy, so changing it doesn't change the parent. - **Anything else** (a list of lists, a dictionary) still goes through `deepcopy`, so nothing a species file could add is shared by mistake. ### Classic shading's photon loop (`vworldr.py`) - The loop is now its own function, `countPhotonsGettingThrough`, so it can be read (and tested) on its own. - Each overlapping canopy's x, y, radius and transmittance are looked up once per plant, not once for every photon. - The distance is worked out inline, with the same `math.hypot` that `pointInsideCircle` uses. - The random numbers are drawn in exactly the same order, so every photon lands where it did before. ### Removing a dead plant (`kill` in `vworldr.py`) `kill` looked through the whole soil list twice for the plant being removed: once to see whether it was there, and again to remove it. With thousands of plants and seeds and 44,000 deaths a run, `kill` took about a quarter of the running time, mostly in those two searches. Now it finds the plant's place once and deletes it there. Dropping the plant's seeds only adds to the end of the soil, so the place stays right. ### Tests - The characterization tests pass unchanged; so do all the unit tests. - A new unit test checks that a seed still gets its own deep copy of anything that isn't a plain list. ### What's left, and Rust Timed without a profiler, a run's time is now spread fairly evenly over looking after Python objects: finding which canopies overlap, making seeds, and germination. None of it is one tight loop of arithmetic any more. I tried the photon loop in Rust. It gives exactly the same results: a copy of Python's own random number generator, and `math.hypot` ported step for step. The loop itself was 4 times faster, but it's only about 8% of a run now, so the whole run went from 10.6 s to 10.1 s. That's not worth adding a Rust toolchain to Vida, so it isn't in this PR. It's kept on the [`experiment-rust-photons`](https://github.com/adamf/vida/tree/experiment-rust-photons/rust) branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013425y5x98TqAv3BvHodpdx --- Vida_Data/vplantr.py | 31 ++++++++++-- Vida_Data/vworldr.py | 87 ++++++++++++++++++++------------- tests/unit/test_plant_growth.py | 16 ++++++ 3 files changed, 98 insertions(+), 36 deletions(-) diff --git a/Vida_Data/vplantr.py b/Vida_Data/vplantr.py index 4a8a2cc..e1ffc81 100755 --- a/Vida_Data/vplantr.py +++ b/Vida_Data/vplantr.py @@ -26,6 +26,21 @@ debug=0 +###What a new seed doesn't copy from its parent (see copyForNewSeed): +###zeroSeedValues resets all of these anyway. +NOT_COPIED_FOR_SEEDS=("motherPlant", "seedList", "overlapList", "subregion") + +###Values that can't be changed in place. +PLAIN_TYPES=(float, int, bool, str, type(None)) + +def isListOfPlainValues(value): + if type(value) is not list: + return False + for item in value: + if type(item) not in PLAIN_TYPES: + return False + return True + #class genericPlant(object): class genericPlant(object): ###define the props on this object @@ -210,10 +225,20 @@ def copyForNewSeed(self): ###of those anyway. So they are left out here, and everything else (the ###species settings, including lists such as the colours) is copied ###just as deepcopy did, so the seed has its own copies. + ###Numbers, strings, True/False and None can't be changed in place, so + ###deepcopy gives back the very same value for them: the seed can + ###simply share them (copy.copy already did that). A list of such + ###values (the colours, the growth records) gets a new list with the + ###same values in it, which is also what deepcopy made. Anything else + ###still goes through deepcopy. theSeed=copy.copy(self) - for key in vars(self): - if key not in ["motherPlant", "seedList", "overlapList", "subregion"]: - setattr(theSeed, key, copy.deepcopy(getattr(self, key))) + for key, value in vars(self).items(): + if type(value) in PLAIN_TYPES or key in NOT_COPIED_FOR_SEEDS: + continue + if isListOfPlainValues(value): + setattr(theSeed, key, list(value)) + else: + setattr(theSeed, key, copy.deepcopy(value)) return theSeed def makeSeed(self, theSeed, theGarden): diff --git a/Vida_Data/vworldr.py b/Vida_Data/vworldr.py index 493f499..bb38fe1 100755 --- a/Vida_Data/vworldr.py +++ b/Vida_Data/vworldr.py @@ -98,6 +98,43 @@ def determineDroughtTol(theGarden): #print("here") theGarden.kill(obj) +def countPhotonsGettingThrough(x, y, r, numbPhotons, covers): + ###Drop numbPhotons photons at random on a plant at (x, y) with radius r, + ###and count how many get through the canopies above it. covers is a list + ###of (x, y, radius, transmittance) for those canopies, in the order of the + ###plant's overlap list. + hitCount=0 + twoPi=3.14*2 + for photon in range(numbPhotons): + ###pick uniformly distributed point in a circle + randAngle=random.random()*twoPi + ##### + ###This is apparently a very old bug dating back to 2009 + ###The way it is written the points cluster toward the center + ###which is exactly what the comments mention it is trying to avoid + ###STH 26 Sept 2026 + + # randr=(random.random()*(plant.r-0))+0 #random between 0 and the radius + # #randr =math.sqrt(randr) #if you don't use sqrt, you get clustering in the center + # randr =randr**0.5 #if you don't use sqrt, you get clustering in the center + randr=r*(random.random()**0.5) #r*sqrt(u) spreads photons evenly over the canopy + ##### + photonX = (randr*math.cos(randAngle))+x + photonY = (randr*math.sin(randAngle))+y + ###The photon stops at the first canopy it lands in (in the order of + ###the overlap list), unless it gets through that canopy. Either way it + ###isn't checked against the rest. (The distance is worked out as in + ###geometry_utils.pointInsideCircle.) + blocked=False + for coverX, coverY, coverR, coverTransmittance in covers: + if math.hypot(coverX-photonX, coverY-photonY)<=coverR: + if random.random() > coverTransmittance: + blocked=True + break + if not blocked: + hitCount=hitCount+1 + return hitCount + def determineShade(theGarden): if theGarden.showProgressBar: print("***Generating lists of overlapping plants. This could take a while...***") @@ -186,37 +223,12 @@ def determineShade(theGarden): numbPhotons= numbPhotons*100 if numbPhotons>750: #we don't need monster numbers numbPhotons=750 - hitCount=0 - for photon in range(numbPhotons): - #####consider moving this to geometry_utils - ###pick uniformly distributed point in a circle - twoPi=3.14*2 - randAngle=random.random()*twoPi - ##### - ###This is apparently a very old bug dating back to 2009 - ###The way it is written the points cluster toward the center - ###which is exactly what the comments mention it is trying to avoid - ###STH 26 Sept 2026 - - # randr=(random.random()*(plant.r-0))+0 #random between 0 and the radius - # #randr =math.sqrt(randr) #if you don't use sqrt, you get clustering in the center - # randr =randr**0.5 #if you don't use sqrt, you get clustering in the center - randr=plant.r*(random.random()**0.5) #r*sqrt(u) spreads photons evenly over the canopy - ##### - - photonX = (randr*math.cos(randAngle))+plant.x - photonY = (randr*math.sin(randAngle))+plant.y - ###### - for overPlant in plant.overlapList: - if not photonX=="gone": - if geometry_utils.pointInsideCircle(overPlant.x, overPlant.y, overPlant.r, photonX, photonY): - randomValue=random.random() - if randomValue > overPlant.canopyTransmittance: - ###these points are where the overlap is - photonX="gone" - break - if not photonX=="gone": - hitCount=hitCount+1 + ###The canopies above, as (x, y, radius, transmittance), looked + ###up once here rather than for every photon. + covers=[] + for overPlant in plant.overlapList: + covers.append((overPlant.x, overPlant.y, overPlant.r, overPlant.canopyTransmittance)) + hitCount=countPhotonsGettingThrough(plant.x, plant.y, plant.r, numbPhotons, covers) if numbPhotons ==0: fractionExposed=0.0 else: @@ -338,7 +350,16 @@ def plantSeed(self, theSeed): def kill(self, theObject): theGarden=self - if theObject in self.soil: + ###Find where theObject is in the soil just once: the soil is a long + ###list, and this used to look through it twice (to see whether + ###theObject was there, and again to remove it). Planting the dropped + ###seeds below only adds to the end of the soil, so the place stays + ###right. + try: + place=self.soil.index(theObject) + except ValueError: + place=None + if place is not None: #die! if len(theObject.seedList)>0: for theSeed in theObject.seedList: @@ -354,7 +375,7 @@ def kill(self, theObject): else: self.numbPlants=self.numbPlants-1 self.deathNote.append(theObject) - self.soil.remove(theObject) + del self.soil[place] def calcEulerGreenhill(self, plant): theGarden=self diff --git a/tests/unit/test_plant_growth.py b/tests/unit/test_plant_growth.py index de05c7f..22efe0d 100644 --- a/tests/unit/test_plant_growth.py +++ b/tests/unit/test_plant_growth.py @@ -410,3 +410,19 @@ def test_a_new_seed_copies_the_species_settings_but_not_the_family(plant, garden assert seed.motherPlant == 0 assert seed.seedList == [] assert len(plant.seedList) == 1 + + +def test_a_new_seed_gets_its_own_copy_of_anything_that_is_not_a_plain_list(plant): + # Lists of numbers are copied with list(); anything else (a list of + # lists, a dictionary) still goes through deepcopy, all the way down. + plant.pairs = [[1, 2], [3, 4]] + plant.table = {"a": [1, 2]} + + seed = plant.copyForNewSeed() + + assert seed.pairs == plant.pairs + assert seed.pairs[0] is not plant.pairs[0] + assert seed.table == plant.table + assert seed.table["a"] is not plant.table["a"] + # numbers and strings are shared, as deepcopy shared them too + assert seed.nameSpecies is plant.nameSpecies