Light distributions, micro-light climate, light above canopy, light extinction coefficients, etc. are key parameters for any canopy simulations. To obtain these, areal or gird like sensor arrangements are required. Both can be generated within GroIMP by only a few lines of code.
Here, a sensor grid is a 3D arrangement of SensorNodes, having a defined number of steps/nodes in x, y, and z direction. When placed within a canopy, such a sensor grid is ideal to measure light distributions within different layers of the canopy. Below an example of the light wheat canopy with three layers of sensor nodes (just for illustration reasons, each layer is coloured differently: from bottom to top, red, green, and blue).
In order to analyse the sensed radiation, e.g., using external tools, it is most probably required to record the location (Cartesian/World coordinates) of each sensor node. One way to do this is to use the Library function location(Object) to get the location of the object. The other way is to record the position during the initialization of the elements. Here, the second way is demonstrate in the following.
To record the position of each sensor node it is needed to have some user defined module that provides variables to store the position, e.g., three simple floats, one for each axis. In XL this can be done in the following way that defines a new module SensorElement as extension of SensorNode:
module SensorElement extends SensorNode() { float x,y,z; public SensorElement(float xi, float yi, float zi) { x = xi; y = yi; z = zi; setRadius(0.1); } }
Using the defined constructor, the position of the SensorElement can be parsed to the element. The following code with create a single SensorElement at (1, 1, 2).
public void init() [ Axiom ==> SensorElement(1, 1, 2); ]
Now, to construct a sensor grid, we basically just need three nested loops arranging our sensors in a three-dimensional grid. The code to do so could look like this:
{ float xi, yi, zi; } for (int x=0;x<6; x++) ( for (int y=0;y<6; y++) ( for (int z=0;z<3; z++) ( [ { xi =-2+0.75*x; yi =-2+0.75*y; zi =0.5*z; } Null(xi, yi, zi) SensorElement(xi, yi, zi) ] ) ) )
When integrated into a small canopy model of binary trees, the results may look like this (side and top view; Note: the Light source is not totally facing to the middle of the scene, but rather slightly sidewards to get some more interesting distribution pattern):
Note: The above code works fine as long as the number of sensor nodes is moderate, e.g., below 100. The drawback of the above code it that it connects all sensor nodes to one parent, what, unfortunately, will slow down computation when the number of children is getting too large. If more sensor nodes are required, be advised implementation uses two helper nodes in order to generate an (internal) tree structure instead of sensor nodes. This will nothing change for the user, but will allow easily thousands of sensor nodes if wanted.
The 'large number of sensor node save code' would look like given below:
{ float xi, yi, zi; } for (int x=0;x<6; x++) ( [Node for (int y=0;y<6; y++) ( [Node for (int z=0;z<3; z++) ( [ { xi =-2+0.75*x; yi =-2+0.75*y; zi =0.5*z; } Null(xi, yi, zi) SensorElement(xi, yi, zi) ] ]) ]) )
The last thing to do in order to make really use of sensor grid is to obtain the sensed radiation for the sensor nodes and also may want to export them for further/external analysis.
public void simulateLight() { // perform light calculation LightModel lm = new LightModel(5000000, 5); lm.compute(); //buffer to store the data StringBuffer sensorData = new StringBuffer(); // see how much was sensed by the sensor [ x:SensorElement ::> { sensorData.append(x.x+", "+x.y+", "+x.z+", "+lm.getSensedIrradiance(x).integrate()+"\n"); } ] //write the data to a file BufferedWriter bwr = new BufferedWriter(new FileWriter(new File("SensorGrid.csv"))); bwr.write(sensorData.toString()); bwr.flush(); bwr.close(); }
The complete code is given below:
//to writing data to a file import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; const String PATH = "/path/to/your/output/folder/"; module SensorElement extends SensorNode() { float x,y,z; public SensorElement(float xi, float yi, float zi) { x = xi; y = yi; z = zi; setRadius(0.1); } } module A(float len) extends Sphere(0.1).(setShader(GREEN)); protected void init() [ {setSeed(12345);} Axiom ==> //create the sensor grid { float xi, yi, zi; } for (int x=0;x<6; x++) ( for (int y=0;y<6; y++) ( for (int z=0;z<3; z++) ( [ { xi =-2+0.75*x; yi =-2+0.75*y; zi =0.5*z; } Null(xi, yi, zi) SensorElement(xi, yi, zi) ] ) ) ) //add a lamp [ Null(0,0,5) RU(170) LightNode.(setLight(new SpotLight().( setPower(100),setVisualize(false), setRaylength(1.5) ))) ] //arrange some plants for (int i=0;i<25; i++) ( [ Null(random(-2,2),random(-2,2),0) RH(random(0,360)) Scale(0.5) A(1) ] ); { derive(); for(apply(5)) run(); } ] private void run () [ A(x) ==> F(x) [RU(30) RH(90) A(x*0.8)] [RU(-30) RH(90) A(x*0.8)]; ] public void simulateLight() { // perform light calculation LightModel lm = new LightModel(5000000, 5); lm.compute(); //buffer to store the data StringBuffer sensorData = new StringBuffer(); // see how much was sensed by the sensors [ x:SensorElement ::> { sensorData.append(x.x+", "+x.y+", "+x.z+", "+lm.getSensedIrradiance(x).integrate()+"\n"); } ] //write the data to a file BufferedWriter bwr = new BufferedWriter(new FileWriter(new File(String.format("%sSensorGrid.csv",PATH)))); bwr.write(sensorData.toString()); bwr.flush(); bwr.close(); //save a snapshot makeSnapshot(String.format("%sSensorGrid.png",PATH)); }
The output of the model will be a database (CSV-file), containing the x, y, and z location and the sensed radiation for each of them.
And when loaded and 3d interpolated using external tools, we nicely can visualize the the 3d light distribution within the canopy.
In a very similar way, areal sensors can be defined. Instead of SensorNode objects, simple flat Box objects are used. Since “real physical” objects are interfering with the 3d scene - what SensorNodes are not doing - the here defined type of areal sensors cannot be used within a canopy. Instead they are used for instance on the ground or behind a canopy.
In the following, a SensorElement is defined as a Box with a black shader. Black to simply absorb any incoming colour.
module SensorElement(float x, float y) extends Box(0.001,dX,dY).(setShader(BLACK));
The sensor elements are arranged in 2d to a closed surface using two nested loops. To make things more general, the dimension (x and y) of the grid as well as the resolution of the singles sensor elements can be defined.
//dimension of wall elements const float WIDTH_X = 1.0; const float WIDTH_Y = 1.0; //dimension of sensor elements const float dX = 0.05; const float dY = 0.05; //number sensor elements const int nX_SE = (int)Math.round(WIDTH_X/dX); const int nY_SE = (int)Math.round(WIDTH_Y/dY); protected void init()[ Axiom ==> //area sensor on the ground { float x,y; } [ for(int i:(0:nY_SE)) ( [ Null(0,0,0) //dummy node to prevent having too many nodes on one parent node { y = 0.5*WIDTH_Y-i*dY; } for(int p:(0:nX_SE)) ( [ { x = 0.5*WIDTH_X -p*dX; } Null(x, y, 0) //move to position SensorElement(x,y) ] ) ] ) ]; ]
Depending on the resolution, the numberer of single sensor elements that build the areal sensor, the resulting absorption pattern will change. Obviously, with decreasing element size, the resolution increases and the absorbed pattern will become sharper. The image below uses from left to right sensor element dimension of 0.2×0.2, 0.1×0.1, 0.05×0.05, and 0.005×0.005 meter. Taking the one by one meter areal sensor size, this leads to 36 (1m/0.2m=5; (5+1)*(5+1)=25) up to 40401 (1m/0.0005m=200; (200+1)*(200+1)=40401) sensor elements.
Queering the SensorElements and exporting the data follows precisely the way of the sensor grid. One small difference, of course, is that for visible objects the getSensedIrradiance function, as it is used for SensorNodes, cannot be applied. Instead, the getAbsorbedPower function needs to be called.
Additionally, in this example, a ColorGradient is applied to colour the sensor elements according to their absorbed radiation. In order to define the right range for the ColorGradient instance, the minimal and maximal absorption values needs to be measured first. This is done whenever the simulateLight function is called and printed to the console window. The obtained values needs to be set to the ColorGradient, consequently, the colouring will be right from the second run one and needs to be redone whenever ether the dimension or the resolution of the areal sensor is changed.
//the colour map for the nice colour gradient const ColorGradient colorMap = new ColorGradient("jet",0.033,0.26, graphState()); //set the min and max values here! public void simulateLight() { ... // see how much was sensed by the sensors float sumAbsorbedPower = 0; float min = 10000; float max = -10000; [ x:SensorElement ::> { float absPower = lm.getAbsorbedPower(x).integrate(); if (absPower<min) { min = absPower; } if (absPower>max) { max = absPower; }; sumAbsorbedPower += absPower; x.setShader(new RGBAShader(colorMap.getColor(absPower))); } ] println("min/max/total = "+min+", "+max+", "+sumAbsorbedPower); ]
Further, a ColorBar is inserted to provide some visual reference. It used the defined colour map as input and visualizes it.
protected void init() [ ... Axiom ==> ... // the colour bar as reference [ Null(-0.75,0,0) Scale(0.3,0.3,0.75) ColorBar(colorMap, true)// colour map, labelling ] ... ; ]
The full code is given below:
import java.awt.Color; //for writing data to a file import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; //dimension of wall elements const float WIDTH_X = 1.0; const float WIDTH_Y = 1.0; //dimension of sensor elements const float dX = 0.05; const float dY = 0.05; //number sensor elements const int nX_SE = (int)Math.round(WIDTH_X/dX); const int nY_SE = (int)Math.round(WIDTH_Y/dY); const String PATH = "/path/to/your/output/folder/"; //the colour map for the nice colour gradient const ColorGradient colorMap = new ColorGradient("jet",0.033,0.26, graphState()); //a single sensor element module SensorElement(float x, float y) extends Box(0.001,dX,dY).(setShader(BLACK)); //////////////////////////////////////////////////////////////////////////////// module A(float len) extends Sphere(0.025).(setShader(GREEN)); protected void init() [ { clearConsole(); println("Number of sensor elements = "+(nX_SE+1)*(nY_SE+1)); } Axiom ==> //area sensor on the ground { float x,y; } [ for(int i:(0:nY_SE)) ( [ Null(0,0,0) { y = 0.5*WIDTH_Y-i*dY; } for(int p:(0:nX_SE)) ( [ { x = 0.5*WIDTH_X -p*dX; } Null(x, y, 0) //move to position SensorElement(x,y) ] ) ] ) ] //just a plane on the ground [ M(-0.01) Box(0.001,2,2).(setShader(new RGBAShader(0.1,0.3,0))) ] // the colour bar as reference [ Null(-0.75,0,0) Scale(0.3,0.3,0.75) ColorBar(colorMap, true)// colour map, labelling ] //add a lamp [ Null(-0.5,-0.5,2) RH(-45) RU(160) LightNode.(setLight(new DirectionalLight().( setPowerDensity(100),setVisualize(false), setRaylength(1.5) ))) ] // add the plant 'seed' A(0.25); { derive(); for(apply(5)) run(); } ] private void run () [ A(x) ==> F(x,0.02) [RU(30) RH(90) A(x*0.8)] [RU(-30) RH(90) A(x*0.8)]; ] //////////////////////////////////////////////////////////////////////////////// public void simulateLight() { // perform light calculation LightModel lm = new LightModel(5000000, 5); lm.compute(); //buffer to store the data StringBuffer sensorData = new StringBuffer(); // see how much was sensed by the sensors float sumAbsorbedPower = 0; float min = 10000; float max = -10000; [ x:SensorElement ::> { float absPower = lm.getAbsorbedPower(x).integrate(); if (absPower<min) { min = absPower; } if (absPower>max) { max = absPower; }; sumAbsorbedPower += absPower; x.setShader(new RGBAShader(colorMap.getColor(absPower))); sensorData.append(x.x+", "+x.y+", "+absPower+"\n"); } ] println("min/max/total = "+min+", "+max+", "+sumAbsorbedPower); repaintView3D(); //write the data to a file BufferedWriter bwr = new BufferedWriter(new FileWriter(new File(String.format("%sSensorGround_%.3f_%.3f.csv",PATH,dX,dY)))); bwr.write(sensorData.toString()); bwr.flush(); bwr.close(); //save a snapshot makeSnapshot(String.format("%sSensorGround_%.3f_%.3f.png",PATH,dX,dY)); }
Using the above code and adding two more areal sensors along the XZ- and YZ-plane, we can get visualizations as the following of an adult tomato plant: