Control Ordering of Parameter in to_radiance

I’ve been having an issue with some custom honeybee python commands I’ve written for performing electric lighting simulations with radiance. I’m working on a research project where I’m batch running potentially thousands of simulations for dynamic luminaires so I wrote some python files for ies2rad and xform that inherit from RadianceCommand and OptionCollection per Sarith’s suggestion here. Generally the commands work great and have been producing results that match my expectations.

So my issue is that the order of the transforms for the Xform command have been inconsistent. Most of my initial models had lights oriented to the world XY plane so I only dealt with translation, but my current model has lights rotated 60°. Sometimes when the command.run() is executed for the Xform it does the Translation first, sometimes the RotateZ, sometimes the Scale. So if it translates the light then rotates it, my lights all wind up way off of their positions. I know this is a custom script, but is there a mechanism for forcing the order that the options parameters are returned when running a command so they’re always consistent? Or perhaps I should override the to_radiance() method on the xform options python file and add some kind of sequence identifier to the class?

-Tim

Hey @logant ,

Have you considered trying to chain multiple Xform commands together using the pipe_to() functionality instead of trying to do it all within a singe Xform command?

Also, if you can share the part of your script where you are setting up the Xform command, we can probably be of more help.

Hey @chris ,

I didn’t even think of chaining the parameters by piping them together in the order I wanted, but I bet that would probably work. I wound up overriding the to_radiance() function in the Xform options class to construct it using the rotation’s first just so that I knew it would be consistent. However, I do recall having a similar problem when trying to create climate based skies where sometimes the ordering of parameters would be inconsistent with what the Gendaylit command expected.

So my general use here is that I have a set of electric lights that I’m first producing custom IES profiles that are specific to a scene/activity. The particular scene I’m running into this has 24 lights, all with unique profiles. For a typical luminance render I’d have a process that looks something like this:

  1. Process IES files (ies2rad and xform) based on a specific scene
  2. Generate the Octree (oconv) using the set of files from Step 1 added to a static model
  3. Render the Image (rpict)

Because of how the fixtures are changing for different scenes/activities, I have a text file in my project directory named ‘luminaires.txt’ that is a CSV containing a list of IDs and the transforms for the lights that are already in a radiance format. Something like this:

luminaires.txt

The processing of the IES files, which reads the luminaire text file and runs Ies2rad and Xform based on happens in the following method. The transform is passed as read from the CSV file to the Xform.options using the update_from_string method.

    @staticmethod
    def process_ies(projPath, scene, color, subtype, multiplier=1.0):
        """
        This will process IES luminaires for a radiance simulation
        and return a list of the processed and transformed Rad files.
        :param projPath: Root path for the radiance project
        :param scene: Name of the scene, prefix of IES profiles to select.
        :param color: Tuple of the (R,G,B) for the light color temperature.
        :param subtype: LUM, SPOT, or LGP
        :return: [ies file paths]
        """

        # Get the Radiance Environment
        env = sculpt.get_env()

        # Iterate through the luminaires.txt file and set up unsculpted ies2rads
        lumfile = os.path.join(projPath, "luminaires.txt")
        lumFiles = []
        if color is None:
            color = (1.0, 0.808, 0.651)
        with open(lumfile) as csvfile:
            reader = csv.reader(csvfile)
            for row in reader:
                idx = row[0]
                t = row[1]

                iespath = os.path.join(projPath, 'ies', 'sculpted')
                initpath = os.path.join(projPath, 'ies', 'temp') + '\\'
                if scene == "unknown":
                    iespath = os.path.join(iespath, "unsculpted.ies")
                    initpath += f"_lum{idx}"
                else:
                    iespath = os.path.join(iespath, f'{scene}_{subtype}_{idx}.ies')
                    initpath += pathlib.Path(iespath).stem
                ies2rad = Ies2rad(None, initpath, iespath)  # type: Ies2rad
                ies2rad.options.c = color
                ies2rad.options.m = multiplier
                ies2rad.run(env, cwd=os.path.dirname(iespath))

                xformPath = os.path.join(projPath, 'ies', 'temp', f"lum_{idx}.rad")
                xform = Xform(None, xformPath, initpath + '.rad')
                xform.options.update_from_string(t)
                # run the xform command
                xform.run(env, cwd=os.path.dirname(iespath))
                lumFiles.append(str(xformPath))
        return lumFiles

And the Xform Command and Options this uses…

"""xform Command."""
import os.path

from .options.xform import XformOptions
from ._command import Command
import warnings
import honeybee_radiance_command._typing as typing


class Xform(Command):
    """Xform command.

        Xform command performs transforms on geometry...

        Args:
            options: Xform command options. It will be set to a null transform
                if unspecified.
            output: Output file (Default: None).
            input: Radiance file to transform (Default: None)

        Properties:
            * options
            * output
            * input
        """
    __slots__ = ('_input',)

    def __init__(self, options=None, output=None, input=None):
        """Initialize Command."""
        Command.__init__(self, output=output)
        self._input = typing.normpath(input)
        self._options = options or XformOptions()

    @property
    def options(self):
        """xform options."""
        return self._options

    @options.setter
    def options(self, value):
        if value is None:
            print('options value is null')
            value = XformOptions()

        if not isinstance(value, XformOptions):
            raise ValueError('Expected XformOptions not {}'.format(type(value)))

        self._options = value

    @property
    def input(self):
        """Input file.

        Get and set inputs files.
        """
        return self._input

    @input.setter
    def input(self, value):
        # ensure input is a valid file path
        if not os.path.exists(value):
            raise ValueError(
                'the input file must be a valid, existing radiance file'
            )
        self._input = typing.path_checker(value)


    def to_radiance(self, stdin_input=False):
        """Xform in Radiance format.

        Args:
            stdin_input: A boolean that indicates if the input for this command
                comes from stdin. This is for instance the case when you pipe the input
                from another command (default: False).
        """
        self.validate()

        command_parts = [self.command]

        if self.options:
            command_parts.append(self.options.to_radiance())

        command_parts.append(self.input)

        cmd = ' '.join(command_parts)

        if self.pipe_to:
            cmd = ' | '.join((cmd, self.pipe_to.to_radiance(stdin_input=True)))
        elif self.output:
            cmd = ' > '.join((cmd, self.output))
        return ' '.join(cmd.split())

    def validate(self):
        Command.validate(self)
        if not os.path.exists(self._input):
            warnings.warn('xform: no valid input file provided.')
"""Xform parameters."""
from .optionbase import OptionCollection, NumericOption, IntegerOption,\
    BoolOption, TupleOption


class XformOptions(OptionCollection):
    """
    ies2rad -m 1 -o %~dp0ies\temp\luminaire_%%a -c %cct% -dm %~dp0ies\sculpted\%scene%_LUM_%%a.ies
    [-m muliplier][-dunits][-l libdir][-p prefdir][-t lamp][-c red green blue][-f lampdat][-u lamp]

    Also see: https://floyd.lbl.gov/radiance/man_html/oconv.1.html
    """

    __slots__ = ('_t', '_rx', '_ry', '_rz', '_s', '_mx', '_my', '_mz', '_i')

    def __init__(self):
        """Ies2rad command options."""
        OptionCollection.__init__(self)
        self._t = TupleOption('t', 'translate the scene along vector x y z', None, 3, float)
        self._rx = NumericOption('rx', 'rotate the scene about the x axis (degrees)', None)
        self._ry = NumericOption('ry', 'rotate the scene about the y axis (degrees)', None)
        self._rz = NumericOption('rz', 'rotate the scene about the z axis (degrees)', None)
        self._s = NumericOption('s', 'scale factor', value=1.0)
        self._mx = BoolOption('mx', 'mirror about the yz plane', None)
        self._my = BoolOption('my', 'mirror about the xz plane', None)
        self._mz = BoolOption('mz', 'mirror about the xy plane', None)
        self._i = IntegerOption('i', 'repeat the following transformations n times.', None)
        self._on_setattr_check = True

    def _on_setattr(self):
        """This method executes after setting each new attribute.

        Use this method to add checks that are necessary for OptionCollection. For
        instance in rtrace option collection -ti and -te are exclusive. You can include a
        check to ensure this is always correct.
        """
        # check if we need this.
        # assert not (self.b.is_set and self.i.is_set), \
        #    'The -b and -i options are mutually exclusive.'

    @property
    def t(self):
        """specify the translation vector"""
        return self._t

    @t.setter
    def t(self, value):
        self._t.value = value

    @property
    def rx(self):
        """specify the x axis rotation in degrees"""
        return self._rx

    @rx.setter
    def rx(self, value):
        self._rx.value = value

    @property
    def ry(self):
        """specify the y axis rotation in degrees"""
        return self._ry

    @ry.setter
    def ry(self, value):
        self._ry.value = value

    @property
    def rz(self):
        """specify the z axis rotation in degrees"""
        return self._rz

    @rz.setter
    def rz(self, value):
        self._rz.value = value

    @property
    def s(self):
        """Specify the scale (1.0 is 100%)"""
        return self._s

    @s.setter
    def s(self, value):
        self._s.value = value

    @property
    def mx(self):
        """mirror across X axis"""
        return self._mx

    @mx.setter
    def mx(self, value):
        self._mx.value = value

    @property
    def my(self):
        """Mirror across Y axis"""
        return self._my

    @my.setter
    def my(self, value):
        self._my.value = value

    @property
    def mz(self):
        """Mirror across Z axis"""
        return self._mz

    @mz.setter
    def mz(self, value):
        self._mz.value = value

    @property
    def i(self):
        """specify iteration quantity"""
        return self._i

    @i.setter
    def i(self, value):
        self._i.value = value

    # force the parameter order
    def to_radiance(self):
        opt = f"-s {self._s.value}"
        if self._rx != None:
            opt += f" -rx {self._rx.value}"
        if self._ry != None:
            opt += f" -ry {self._ry.value}"
        if self._rz != None:
            opt += f" -rz {self._rz.value}"
        if self._mx != None:
            opt += f" -mx {self._mx.value}"
        if self._my != None:
            opt += f" -my {self._my.value}"
        if self._mz != None:
            opt += f" -mz {self._mz.value}"
        if self._t != None:
            opt += f" {self._t.to_radiance()}"
        if self._i != None:
            opt += f" -i {self._i.value}"
        return opt

I’ll take a stab at splitting out the transforms and piping them together and report back how that turns out.

-Tim

Separating the transforms to separate commands that are piped together appears to work fairly well. I had to fix my Xform command so that it played nice with piping commands together by not forcing it to have a valid input file, but that should have been done from the beginning. Once I got that worked out I was able to chain chain the commands and have it reliably place and orient my electric lights.

Changes to the Xform command’s to_radiance method:

def to_radiance(self, stdin_input=False):
    """Xform in Radiance format.

    Args:
        stdin_input: A boolean that indicates if the input for this command
            comes from stdin. This is for instance the case when you pipe the input
            from another command (default: False).
    """
    self.validate()

    command_parts = [self.command]

    if self.options:
        command_parts.append(self.options.to_radiance())

    command_parts.append('' if stdin_input else self.input)
    
    cmd = ' '.join(command_parts)

    if self.pipe_to:
        pt = self.pipe_to.to_radiance(stdin_input=True)
        cmd = ' | '.join((cmd, pt))
        
    elif self.output:
        cmd = ' > '.join((cmd, self.output))
        
    return ' '.join(cmd.split())

Changes to how I’m parsing and constructing the xform commands while processing the IES files:

@staticmethod
def split_xforms(xform):
    split = xform.split("-")
    xforms = []

    current = ""
    for part in split:
        if part == "":
            continue
        if part[0].isalpha():
            if current != "":
                xforms.append(current)
            current = f"-{part}"
        else:
            current += f"-{part}"
    xforms.append(current)
    return xforms

@staticmethod
def process_ies(projPath, scene, color, subtype, multiplier=1.0):
    """
    This will process IES luminaires for a radiance simulation
    and return a list of the processed and transformed Rad files.
    :param projPath: Root path for the radiance project
    :param scene: Name of the scene, prefix of IES profiles to select.
    :param color: Tuple of the (R,G,B) for the light color temperature.
    :param subtype: LUM, SPOT, or LGP
    :return: [ies file paths]
    """

    # Get the Radiance Environment
    env = sculpt.get_env()

    # Iterate through the luminaires.txt file and set up unsculpted ies2rads
    lumfile = os.path.join(projPath, "luminaires.txt")
    lumFiles = []
    if color is None:
        color = (1.0, 0.808, 0.651)
    with open(lumfile) as csvfile:
        reader = csv.reader(csvfile)
        for row in reader:
            idx = row[0]
            t = row[1]

            # new method to separate the transforms
            xforms = sculpt.split_xforms(t)

            iespath = os.path.join(projPath, 'ies', 'sculpted')
            initpath = os.path.join(projPath, 'ies', 'temp') + '\\'
            if scene == "unknown":
                iespath = os.path.join(iespath, "unsculpted.ies")
                initpath += f"_lum{idx}"
            else:
                iespath = os.path.join(iespath, f'{scene}_{subtype}_{idx}.ies')
                initpath += pathlib.Path(iespath).stem
            ies2rad = Ies2rad(None, initpath, iespath)  # type: Ies2rad
            ies2rad.options.c = color
            ies2rad.options.m = multiplier
            ies2rad.run(env, cwd=os.path.dirname(iespath))

            xformPath = os.path.join(projPath, 'ies', 'temp', f"lum_{idx}.rad")

            # New setup to build and chain the xforms together
            all_xforms = []
            for i in range(len(xforms) - 1, -1, -1):
                ipath = "" if i > 0 else f"{initpath}.rad"
                opath = None if i < len(xforms) - 1 else xformPath
                x = Xform(None, opath, ipath)
                x.options.update_from_string(xforms[i])
                if i < len(xforms) - 1:
                    x.pipe_to = all_xforms[-1]
                all_xforms.append(x)
            lx = all_xforms[-1]
            lx.run(env)
            lumFiles.append(str(xformPath))
    return lumFiles