diff --git a/src/raster/r.surf.volcano/Makefile b/src/raster/r.surf.volcano/Makefile
new file mode 100644
index 0000000000..b0e30f319f
--- /dev/null
+++ b/src/raster/r.surf.volcano/Makefile
@@ -0,0 +1,7 @@
+MODULE_TOPDIR = ../..
+
+PGM = r.surf.volcano
+
+include $(MODULE_TOPDIR)/include/Make/Script.make
+
+default: script
diff --git a/src/raster/r.surf.volcano/r.surf.volcano.html b/src/raster/r.surf.volcano/r.surf.volcano.html
new file mode 100644
index 0000000000..a685ef21fe
--- /dev/null
+++ b/src/raster/r.surf.volcano/r.surf.volcano.html
@@ -0,0 +1,97 @@
+
DESCRIPTION
+
+
+r.surf.volcano creates an artificial surface resembling a seamount
+or cone volcano. The user can alter the size and shape of the mountain and
+optionally roughen its surface.
+
+
+NOTES
+
+The friction of distance controls the shape of the mountain when using
+the default polynomial method. Higher values generate steeper slopes.
+
+The pseudo-kurtosis factor is used with all other methods
+to control the slope steepness. For the Gaussian method setting the value
+nearer to zero creates a flatter surface, while higher values generate
+steeper slopes. For Lorentzian, logarithmic, and exponential methods the
+opposite is true.
+
+The surface roughness factor controls the fixed standard deviation
+distance (sigma) used in the Gaussian random number generator.
+It is only used when the -r roughen surface flag is turned on.
+A value closer to zero makes a smoother surface, a higher value makes
+a rougher surface.
+
+It is possible to set a negative value for the peak in order
+to create a pit.
+
+
+
EXAMPLES
+
+Create a simple Gaussian bell:
+
+r.surf.volcano -r output=seamount method=gaussian
+
+# view in the display monitor
+r.colors seamount color=roygbiv
+d.rast seamount
+
+# render in 3D
+m.nviz.image elevation_map=seamount out=gaussian \
+ perspective=10 resolution_fine=1 height=3500
+pnmtopng gaussian.ppm > gaussian.png
+
+# export to Matlab
+r.out.mat in=seamount out=seamount.mat
+
+# integrate into existing DEM
+r.mapcalc "seamount_dem = if(seamount > dem, seamount, dem)"
+r.colors seamount_dem color=srtm
+
+
+
+
+
Gaussian bell
+
+
+
+Create a roughened volcano with a crater:
+
+r.surf.volcano -r output=volcano crater=250 --verbose
+r.relief in=volcano out=volcano.shade
+
+
+Create a fancy 3D scene:
+
+r.surf.volcano -r output=base_volcano peak=1000 crater=200
+r.surf.fractal output=base_fractal
+r.mapcalc "artificial_land = base_volcano*3.5 + base_fractal"
+
+m.nviz.image elevation_map=artificial_land out=volcano3D \
+ perspective=25 resolution_fine=1 height=30000
+pnmtopng volcano3D.ppm > volcano3D.png
+
+
+
+
+
+
Synthetic volcano from r.surf.volcano combined with fractal
+landscape from r.surf.fractal
+
+
+
+SEE ALSO
+
+
+r.surf.fractal
+r.surf.gauss
+r.surf.random
+
+
+AUTHOR
+
+Hamish Bowman
+Dept. of Geology
+University of Otago
+Dunedin, New Zealand
diff --git a/src/raster/r.surf.volcano/r.surf.volcano.py b/src/raster/r.surf.volcano/r.surf.volcano.py
new file mode 100755
index 0000000000..3f7eae9eef
--- /dev/null
+++ b/src/raster/r.surf.volcano/r.surf.volcano.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+############################################################################
+#
+# MODULE: r.surf.volcano
+#
+# AUTHOR: M. Hamish Bowman, Dept. of Geology, University of Otago
+# Dunedin, New Zealand
+# Ported to Python from GRASS 6 addons shell script 8/2024
+#
+# PURPOSE: Create an artificial surface resembling a seamount or cone volcano
+#
+# COPYRIGHT: (c) 2009-2024 Hamish Bowman, and the GRASS Development Team
+# This program is free software under the GNU General Public
+# License (>=v2). Read the file COPYING that comes with GRASS
+# for details.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+#############################################################################
+
+# %Module
+# % description: Creates an artificial surface resembling a seamount or cone volcano.
+# % keyword: raster
+# %End
+# %Option G_OPT_R_OUTPUT
+# %End
+# %Option
+# % key: peak
+# % type: double
+# % required: no
+# % description: Height of cone
+# % answer: 1000.0
+# %End
+# %Option
+# % key: crater
+# % type: double
+# % required: no
+# % label: Depth of crater below the cone
+# % description: A larger (deeper) value here also means a wider crater.
+# % answer: 0.0
+# %End
+# %Option
+# % key: method
+# % type: string
+# % required: no
+# % description: Mathematical function for creating the mountain
+# % answer: polynomial
+# % options: polynomial,gaussian,lorentzian,logarithmic,exponential
+# % descriptions: polynomial;1/distance^n;gaussian;Gaussian function;lorentzian;Cauchy-Lorentz distribution;logarithmic;Logarithmic decay;exponential;Exponential decay
+# %End
+# %Option
+# % key: friction
+# % type: integer
+# % required: no
+# % options: 1-25
+# % label: Polynomial friction of distance, (the 'n' in 1/d^n)
+# % description: Higher values generate steeper slopes. (only used with the polynomial method)
+# % answer: 6
+# %End
+# # FIXME: ok, it isn't really kurtosis but it's similar and I couldn't
+# # think of a better name.
+# %Option
+# % key: kurtosis
+# % type: double
+# % required: no
+# % label: Slope steepness (used with all methods except polynomial)
+# % description: For Gaussian: nearer to 0 is flatter, higher values generate steeper slopes. For Lorentzian, logarithmic, and exponential the opposite is true.
+# % answer: 1.0
+# %End
+# %Option
+# % key: sigma
+# % type: double
+# % required: no
+# % label: Surface roughness factor
+# % description: Nearer to 0 is smoother, higher values make a rougher surface.
+# % answer: 1.0
+# %End
+# %Flag
+# % key: r
+# % description: Roughen the surface
+# %End
+
+import os
+import sys
+from math import pi
+from grass.script import core as gc
+from grass.script import raster as gr
+
+# from grass.exceptions import CalledModuleError
+
+
+def remove_rast(maps):
+ """Remove raster maps"""
+ gc.run_command("g.remove", flags="f", quiet=True, type="rast", name=maps)
+
+
+def main():
+ outmap = options["output"]
+ method = options["method"]
+ friction = options["friction"]
+ peak = options["peak"]
+ crater = options["crater"]
+ sigma = options["sigma"]
+ kurtosis = options["kurtosis"]
+
+ tmp_base = "tmp__rsv_%d" % os.getpid()
+ map_dist_units = "%s_dist_units" % tmp_base
+ map_dist_norm = "%s_dist_norm" % tmp_base
+ map_peak = "%s_peak" % tmp_base
+ map_surf = "%s_surf" % tmp_base
+
+ gc.verbose(_("Finding cost from center of current region ..."))
+
+ region = gc.region(complete=True)
+
+ gr.mapcalc(
+ "$mdu = sqrt( (x() - $ce)^2 + (y() - $cn)^2 )",
+ mdu=map_dist_units,
+ ce=region["center_easting"],
+ cn=region["center_northing"],
+ )
+
+ gc.verbose(_("Normalizing cost map ..."))
+ rinfo = gr.raster_info(map_dist_units)
+
+ if method == "polynomial":
+ # Normalize with 1 in the center and 0 at outer edge
+ # r.mapcalc "volc.dist_norm.$$ = ($max - volc.dist_units.$$) / $max"
+ gr.mapcalc(
+ "$mdn = ($max - $mdu) / $max",
+ max=rinfo["max"],
+ mdn=map_dist_norm,
+ mdu=map_dist_units,
+ )
+ else:
+ # Normalize with 0 in the center and 1 at outer edge
+ # r.mapcalc "volc.dist_norm.$$ = volc.dist_units.$$ / $max"
+ gr.mapcalc(
+ "$mdn = $mdu / $max",
+ max=rinfo["max"],
+ mdn=map_dist_norm,
+ mdu=map_dist_units,
+ )
+
+ ## ##
+ # create peak map #
+ ## ##
+
+ if method == "polynomial":
+ gc.verbose(_("Creating IDW surface ..."))
+ # r.mapcalc "volc.peak.$$ = ($PEAK + abs($CRATER) ) \
+ # * pow( volc.dist_norm.$$, $FRICTION )"
+ gr.mapcalc(
+ "$mp = ($pk + abs($crat)) * pow($mdn, $fric)",
+ mp=map_peak,
+ pk=peak,
+ crat=crater,
+ mdn=map_dist_norm,
+ fric=friction,
+ )
+ elif method == "gaussian":
+ gc.verbose(_("Creating Gaussian surface ..."))
+ # % description: Use a Gaussian curve instead of 1/(d^n) for radial basis function
+ # normalized Gaussian curve: f(x) = a * e^( (x-b)^2 / 2*(c^2) )
+ # parameters: a = 1/(sigma*sqrt(2pi)), b = mu, c = sigma
+ # mu is mean value and sigma^2 is variance.
+ # so we only need b,c. and b can be locked at 0 here. so user only needs
+ # to give sigma (width)
+ # thus r.mapcalc expr could look like
+ # f(distance) = ( 1 / ($SIGMA*sqrt(2*$PI)) ) * exp( -1* $DIST^2 / (2 * $SIGMA^2) )
+
+ map_gauss = "%s_gauss" % tmp_base
+ SIGMA_C = 1.0
+
+ ## FIXME: the 10*kurtosis stuff is a completely bogus hack!
+ # r.mapcalc "volc.gauss.$$ = \
+ # ( 1 / ( $SIGMA_C * sqrt(2 * $PI) ) ) \
+ # * exp( -1* (10 * $KURTOSIS * volc.dist_norm.$$)^2 / (2 * $SIGMA_C^2) )"
+ gr.mapcalc(
+ "$mg = ( 1 / ( $sigC * sqrt(2 * $pi) ) ) \
+ * exp( -1* (10 * $kt * $mdn)^2 / (2 * $sigC^2) )",
+ mg=map_gauss,
+ sigC=SIGMA_C,
+ pi=pi,
+ kt=kurtosis,
+ mdn=map_dist_norm,
+ )
+
+ rinfo = gr.raster_info(map_gauss)
+ gc.verbose(_("Normalizing Gaussian surface ..."))
+ # r.mapcalc "volc.peak.$$ = \
+ # ( ($PEAK + abs($CRATER) ) / $max ) * volc.gauss.$$"
+ gr.mapcalc(
+ "$mp = ( ($pk + abs($crat) ) / $max ) * $mg",
+ mp=map_peak,
+ pk=peak,
+ crat=crater,
+ max=rinfo["max"],
+ mg=map_gauss,
+ )
+
+ remove_rast(map_gauss)
+ elif method == "lorentzian":
+ # Cauchy-Lorentz fn: f(distance, gamma, height) =
+ # height_of_peak * ( gamma^2 / ( distance^2 + gamma^2) )
+ # where gamma is the scale parameter giving half-width at half-maximum.
+ gc.verbose(_("Creating Lorentzian surface ..."))
+ # r.mapcalc "volc.peak.$$ = ($PEAK + abs($CRATER) ) \
+ # * ( ($KURTOSIS * 0.1)^2 / ( volc.dist_norm.$$ ^2 + ($KURTOSIS * 0.1)^2) )"
+ gr.mapcalc(
+ "$mp = ($pk + abs($crat) ) * ( ($kt * 0.1)^2 / ($mdn^2 + ($kt * 0.1)^2) )",
+ mp=map_peak,
+ pk=peak,
+ crat=crater,
+ kt=kurtosis,
+ mdn=map_dist_norm,
+ )
+ elif method == "exponential":
+ # exponential: 1 / ((e^distance) -1)
+ gc.verbose(_("Creating exponential decay surface ..."))
+ map_exp = "%s_exp" % tmp_base
+
+ # r.mapcalc "volc.exp.$$ = 1 / (exp(volc.dist_norm.$$ / $KURTOSIS) - 0.9)"
+ gr.mapcalc(
+ "$me = 1 / ( exp($mdn / $kt) ) - 0.9",
+ me=map_exp,
+ mdn=map_dist_norm,
+ kt=kurtosis,
+ )
+ rinfo = gr.raster_info(map_exp)
+
+ gc.verbose(_("Normalizing exponential surface ..."))
+ # r.mapcalc "volc.peak.$$ = \
+ # ( ($PEAK + abs($CRATER) ) / $max ) * volc.exp.$$"
+ gr.mapcalc(
+ "$mp = ( ($pk + abs($crat) ) / $max ) * $me",
+ mp=map_peak,
+ pk=peak,
+ crat=crater,
+ max=rinfo["max"],
+ me=map_exp,
+ )
+
+ remove_rast(map_exp)
+ elif method == "logarithmic":
+ # logarithmic: 1 / ( (d+1)^2 * log(d+1) )
+ gc.verbose(_("Creating logarithmic decay surface ..."))
+ map_log = "%s_log" % tmp_base
+
+ # r.mapcalc "volc.log.$$ = 1 / \
+ # ( (volc.dist_norm.$$ + pow(1.15,$KURTOSIS))^2 \
+ # * log((volc.dist_norm.$$) + pow(1.15,$KURTOSIS)) )"
+ gr.mapcalc(
+ "$ml = 1 / ( ($mdn + pow(1.15, $kt))^2 * log(($mdn) + pow(1.15, $kt)) )",
+ ml=map_log,
+ mdn=map_dist_norm,
+ kt=kurtosis,
+ )
+
+ rinfo = gr.raster_info(map_log)
+ gc.verbose(_("Normalizing logarithmic surface ..."))
+ # r.mapcalc "volc.peak.$$ = \
+ # ( ($PEAK + abs($CRATER) ) / $max ) * volc.log.$$"
+ gr.mapcalc(
+ "$mp = ( ($pk + abs($crat) ) / $max ) * $ml",
+ mp=map_peak,
+ pk=peak,
+ crat=crater,
+ max=rinfo["max"],
+ ml=map_log,
+ )
+
+ remove_rast(map_log)
+ else:
+ gc.error("Programmer error, method = <%s>" % method)
+ sys.exit(1)
+
+ if flags["r"]:
+ # roughen it up a bit
+ gc.verbose(_("Creating random Gaussian mottle ..."))
+ map_surf_gauss = "%s_surf_gauss" % tmp_base
+ map_peak_rough = "%s_peak_rough" % tmp_base
+
+ gc.run_command("r.surf.gauss", output=map_surf_gauss, sigma=sigma)
+
+ gc.verbose(_("Applying Gaussian mottle ..."))
+ # r.mapcalc "volc.peak_rough.$$ = \
+ # volc.peak.$$ + (volc.surf_gauss.$$ * $PEAK/400 )"
+ gr.mapcalc(
+ "$vpr = $vp + ($vsg * $pk/400)",
+ vpr=map_peak_rough,
+ vp=map_peak,
+ vsg=map_surf_gauss,
+ pk=peak,
+ )
+
+ gc.run_command("g.rename", raster=(map_peak_rough, map_surf), quiet=True)
+ remove_rast([map_surf_gauss, map_peak])
+
+ else: # no '-r'
+ gc.run_command("g.rename", raster=(map_peak, map_surf), quiet=True)
+
+ remove_rast([map_dist_units, map_dist_norm])
+
+ if float(crater) != 0:
+ gc.verbose(_("Creating crater ..."))
+ map_combo = "%s_full" % tmp_base
+ # r.mapcalc "volc.full.$$ = if( volc.surf.$$ > $PEAK, \
+ # 2*$PEAK - volc.surf.$$, volc.surf.$$ )"
+ gr.mapcalc(
+ "$vc = if($vs > $pk, 2*$pk - $vs, $vs)", vc=map_combo, vs=map_surf, pk=peak
+ )
+
+ gc.run_command("g.rename", raster=(map_combo, outmap), quiet=True)
+ remove_rast(map_surf)
+
+ else:
+ gc.run_command("g.rename", raster=(map_surf, outmap), quiet=True)
+
+ # DCELL is overkill here, convert what we made into FCELL
+ outmapD = "%s_DCELL" % tmp_base
+ gc.run_command("g.rename", raster=(outmap, outmapD), quiet=True)
+ gr.mapcalc("$out = float($outD)", out=outmap, outD=outmapD, quiet=True)
+ remove_rast(outmapD)
+
+ # test if it worked
+ if not gc.find_file(outmap, mapset=".")["name"]:
+ gc.error(_("Surface creation failed"))
+ sys.exit(1)
+
+ # write metadata
+ gc.run_command(
+ "r.support",
+ map=outmap,
+ description="generated by r.surf.volcano",
+ source1="Peak height = %s" % peak,
+ title="Artificial surface resembling a seamount or cone volcano",
+ )
+
+ if flags["r"]:
+ gc.run_command(
+ "r.support",
+ map=outmap,
+ history="Surface roughness used a Gaussian deviate with sigma of %s."
+ % sigma,
+ )
+
+ if float(crater) != 0:
+ gc.run_command("r.support", map=outmap, history="Crater depth %s." % crater)
+
+ if method == "polynomial":
+ s2msg = "Polynomial surface with friction of distance = %s" % friction
+ elif method == "gaussian":
+ s2msg = "Gaussian surface with pseudo-kurtosis factor = %s" % kurtosis
+ elif method == "lorentzian":
+ s2msg = "Lorentzian surface with pseudo-kurtosis factor = %s" % kurtosis
+ elif method == "exponential":
+ s2msg = "Exponential decay surface with pseudo-kurtosis factor = %s" % kurtosis
+ elif method == "logarithmic":
+ s2msg = "Logarithmic decay surface with pseudo-kurtosis factor = %s" % kurtosis
+ else:
+ gc.error("Programmer error, method = <%s>" % method)
+ sys.exit(1)
+
+ gc.run_command("r.support", map=outmap, source2=s2msg)
+
+ # perhaps,
+ # r.colors map=output color=srtm --quiet
+
+ # cleanup on abort/cancel
+ # FIXME: superquiet isn't
+ # TODO: make tmp_base global and move into cleanup_rast() error fn
+ # gc.run_command(
+ # "g.remove", flags="f", superquiet=True, type="rast", patt="%s_*" % tmp_base
+ # )
+
+ gc.verbose(_("Done."))
+
+
+if __name__ == "__main__":
+ options, flags = gc.parser()
+ main()
diff --git a/src/raster/r.surf.volcano/r_surf_volcano_gaussian.jpg b/src/raster/r.surf.volcano/r_surf_volcano_gaussian.jpg
new file mode 100644
index 0000000000..0a08394562
Binary files /dev/null and b/src/raster/r.surf.volcano/r_surf_volcano_gaussian.jpg differ
diff --git a/src/raster/r.surf.volcano/r_surf_volcano_volcano3D.jpg b/src/raster/r.surf.volcano/r_surf_volcano_volcano3D.jpg
new file mode 100644
index 0000000000..9e1c07d8f2
Binary files /dev/null and b/src/raster/r.surf.volcano/r_surf_volcano_volcano3D.jpg differ
diff --git a/src/raster/r.surf.volcano/testsuite/test_rvolc.sh b/src/raster/r.surf.volcano/testsuite/test_rvolc.sh
new file mode 100755
index 0000000000..14bb5e1bea
--- /dev/null
+++ b/src/raster/r.surf.volcano/testsuite/test_rvolc.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# Test case for r.surf.volcano: check that resulting raster map
+# is within some arbitrary threshold of matching the map I get.
+# Exits with 0 if everything passed, 1 if something failed.
+# Hamish Bowman Sept. 2024
+#
+# See gunittest_testing.html's basic example for a pretty relevant
+# set of tests. See also r.surf.random's tests for inspiration.
+
+# Tested within the NC sample dataset (nc_spm_08).
+g.region n=5120 s=0 w=0 e=5120 res=10
+
+# touches r.mapcalc, g.region, r.info, g.rename, g.remove
+r.surf.volcano method=gaussian output="volcano.gauss.$$"
+
+if [ $? -ne 0 ] ; then
+ echo "ERROR r.surf.volcano test: did not exit cleanly" 1>&2
+ exit 1
+fi
+
+eval `r.univar -g "volcano.gauss.$$"`
+g.remove -f rast name="volcano.gauss.$$"
+
+
+# reference values
+ref_max=1000
+ref_mean=31.3
+ref_stddev=121.1
+
+# completely arbitrary cross platform variance threshold
+var=5
+
+
+result=`echo "$max $ref_max $var" | awk \
+ 'function abs(x){return ((x < 0) ? -x : x)}
+ {if( abs($1 - $2) < $3 ) {print "ok"}
+ else {print "bad"} }'`
+
+if [ "$result" = "bad" ] ; then
+ echo "ERROR r.surf.volcano test: out of bounds raster maximum" 1>&2
+ exit 1
+fi
+
+
+result=`echo "$mean $ref_mean $var" | awk \
+ 'function abs(x){return ((x < 0) ? -x : x)}
+ {if( abs($1 - $2) < $3 ) {print "ok"}
+ else {print "bad"} }'`
+
+if [ "$result" = "bad" ] ; then
+ echo "ERROR r.surf.volcano test: out of bounds raster mean" 1>&2
+ exit 1
+fi
+
+
+result=`echo "$stddev $ref_stddev $var" | awk \
+ 'function abs(x){return ((x < 0) ? -x : x)}
+ {if( abs($1 - $2) < $3 ) {print "ok"}
+ else {print "bad"} }'`
+
+if [ "$result" = "bad" ] ; then
+ echo "ERROR r.surf.volcano test: out of bounds raster std.dev." 1>&2
+ exit 1
+fi
+
+
+# all good
+exit 0
+